From bd9beb38603c877f21a4fb5b5b7025b9347b822a Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 14 Nov 2018 16:20:35 +0100 Subject: [PATCH 01/38] Rework GUI to support GigaChannel This patch replaces QT List-based view of the channels and their contents with a TableView-based widget that uses QT MVC idiom. The provided TableModel is capable of loading data lazily from the REST endpoint. The corresponding endpoint is modified to send the data in small pages and do sorts and searches. The TableView serves the model with a custom Delegate. --- .../Core/APIImplementation/LaunchManyCore.py | 88 +- Tribler/Core/CacheDB/SqliteCacheDBHandler.py | 126 +- Tribler/Core/CacheDB/sqlitecachedb.py | 1 + Tribler/Core/Config/tribler_config.py | 6 - .../OrmBindings/channel_metadata.py | 164 +- .../MetadataStore/OrmBindings/metadata.py | 58 +- .../OrmBindings/torrent_metadata.py | 13 +- .../Modules/MetadataStore/serialization.py | 27 +- Tribler/Core/Modules/MetadataStore/store.py | 37 +- Tribler/Core/Modules/gigachannel_manager.py | 117 + .../channels/channels_discovered_endpoint.py | 97 +- .../channels_subscription_endpoint.py | 49 +- .../channels/channels_torrents_endpoint.py | 222 +- .../restapi/channels/my_channel_endpoint.py | 97 +- .../Modules/restapi/downloads_endpoint.py | 37 +- .../Core/Modules/restapi/events_endpoint.py | 4 - .../Core/Modules/restapi/search_endpoint.py | 260 +- Tribler/Core/Modules/restapi/util.py | 77 +- .../Community/gigachannel/test_community.py | 247 +- .../gigachannel/test_community_fullsession.py | 130 - .../gigachannel/test_sync_strategy.py | 6 +- .../Test/Core/Config/test_tribler_config.py | 2 - .../test_credit_mining_sources.py | 18 - .../MetadataStore/test_channel_download.py | 15 +- .../MetadataStore/test_channel_metadata.py | 35 +- .../Core/Modules/MetadataStore/test_store.py | 7 +- .../MetadataStore/test_torrent_metadata.py | 25 +- .../test_channels_discovered_endpoint.py | 125 +- .../Channels/test_channels_endpoint.py | 40 +- .../test_channels_subscription_endpoint.py | 37 +- .../test_channels_torrents_endpoint.py | 218 +- .../Channels/test_create_channel_endpoint.py | 112 - .../Channels/test_my_channel_endpoints.py | 55 +- .../RestApi/test_downloads_endpoint.py | 9 +- .../Modules/RestApi/test_events_endpoint.py | 1 - .../Core/Modules/RestApi/test_rest_manager.py | 24 +- .../Modules/RestApi/test_search_endpoint.py | 126 +- .../Test/Core/Modules/RestApi/test_util.py | 20 +- Tribler/Test/Core/Utilities/test_utilities.py | 2 +- .../000000000001.mdblob | Bin 0 -> 265 bytes .../000000000003.mdblob | Bin 0 -> 561 bytes .../000000000004.mdblob | Bin 0 -> 218 bytes .../000000000005.mdblob | Bin 0 -> 300 bytes .../000000000006.mdblob | Bin 0 -> 298 bytes .../Core/data/sample_channel/channel.mdblob | Bin 220 -> 230 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 220 -> 230 bytes .../data/sample_channel/channel_upd.torrent | 2 +- .../000000000001.mdblob | Bin 265 -> 0 bytes .../000000000003.mdblob | Bin 561 -> 0 bytes .../000000000004.mdblob | Bin 300 -> 0 bytes .../Test/Core/test_sqlitecachedbhandler.py | 1 - .../test_sqlitecachedbhandler_channels.py | 4 +- .../test_sqlitecachedbhandler_torrents.py | 7 +- Tribler/Test/GUI/test_gui.py | 19 - Tribler/Test/mocking/channel.py | 8 + Tribler/Test/mocking/session.py | 5 +- Tribler/community/gigachannel/community.py | 175 +- Tribler/community/gigachannel/payload.py | 54 - .../community/gigachannel/sync_strategy.py | 6 +- TriblerGUI/defs.py | 12 +- TriblerGUI/dialogs/confirmationdialog.py | 11 +- TriblerGUI/event_request_manager.py | 6 +- TriblerGUI/images/check.svg | 54 + TriblerGUI/images/minus.svg | 69 + TriblerGUI/images/plus.svg | 45 + TriblerGUI/images/undo.svg | 50 + TriblerGUI/qt_resources/buttonsdialog.ui | 40 + TriblerGUI/qt_resources/channel_list_item.ui | 430 ---- .../qt_resources/channel_torrent_list_item.ui | 628 ----- TriblerGUI/qt_resources/channel_view.ui | 1493 +++++++++++ TriblerGUI/qt_resources/mainwindow.ui | 2178 ++--------------- TriblerGUI/qt_resources/playlist_list_item.ui | 414 ---- .../torrent_channel_list_container.ui | 7 +- TriblerGUI/tribler_window.py | 93 +- TriblerGUI/widgets/channel_list_item.py | 21 - .../widgets/channel_torrent_list_item.py | 198 -- TriblerGUI/widgets/channelpage.py | 102 +- TriblerGUI/widgets/channelview.py | 446 ++++ TriblerGUI/widgets/createtorrentpage.py | 2 +- TriblerGUI/widgets/discoveredpage.py | 49 +- TriblerGUI/widgets/editchannelpage.py | 560 +---- TriblerGUI/widgets/homepage.py | 1 + TriblerGUI/widgets/lazyloadlist.py | 79 - TriblerGUI/widgets/lazytableview.py | 629 +++++ TriblerGUI/widgets/leftmenuplaylist.py | 1 + TriblerGUI/widgets/manageplaylistpage.py | 154 -- TriblerGUI/widgets/playlist_list_item.py | 45 - TriblerGUI/widgets/playlistpage.py | 26 - TriblerGUI/widgets/searchresultspage.py | 134 +- TriblerGUI/widgets/subscribedchannelspage.py | 35 +- TriblerGUI/widgets/subscriptionswidget.py | 6 +- TriblerGUI/widgets/torrentdetailstabwidget.py | 59 +- gen_db.py | 0 94 files changed, 4449 insertions(+), 6645 deletions(-) create mode 100644 Tribler/Core/Modules/gigachannel_manager.py delete mode 100644 Tribler/Test/Community/gigachannel/test_community_fullsession.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_create_channel_endpoint.py create mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob create mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob create mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000004.mdblob create mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob create mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000001.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000003.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000004.mdblob delete mode 100644 Tribler/community/gigachannel/payload.py create mode 100644 TriblerGUI/images/check.svg create mode 100644 TriblerGUI/images/minus.svg create mode 100644 TriblerGUI/images/plus.svg create mode 100644 TriblerGUI/images/undo.svg delete mode 100644 TriblerGUI/qt_resources/channel_list_item.ui delete mode 100644 TriblerGUI/qt_resources/channel_torrent_list_item.ui create mode 100644 TriblerGUI/qt_resources/channel_view.ui delete mode 100644 TriblerGUI/qt_resources/playlist_list_item.ui delete mode 100644 TriblerGUI/widgets/channel_list_item.py delete mode 100644 TriblerGUI/widgets/channel_torrent_list_item.py create mode 100644 TriblerGUI/widgets/channelview.py delete mode 100644 TriblerGUI/widgets/lazyloadlist.py create mode 100644 TriblerGUI/widgets/lazytableview.py delete mode 100644 TriblerGUI/widgets/manageplaylistpage.py delete mode 100644 TriblerGUI/widgets/playlist_list_item.py delete mode 100644 TriblerGUI/widgets/playlistpage.py create mode 100644 gen_db.py diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 1f0dc9abf4d..8429ac70081 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -15,6 +15,7 @@ from threading import Event, enumerate as enumerate_threads from traceback import print_exc +from six import text_type from pony.orm import db_session from six import text_type @@ -26,9 +27,9 @@ from twisted.python.threadable import isInIOThread from Tribler.Core.CacheDB.sqlitecachedb import forceDBThread -from Tribler.Core.DownloadConfig import DefaultDownloadStartupConfig, DownloadStartupConfig -from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload, float2time +from Tribler.Core.DownloadConfig import DownloadStartupConfig, DefaultDownloadStartupConfig from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Modules.gigachannel_manager import GigaChannelManager from Tribler.Core.Modules.payout_manager import PayoutManager from Tribler.Core.Modules.resource_monitor import ResourceMonitor from Tribler.Core.Modules.search_manager import SearchManager @@ -121,6 +122,7 @@ def __init__(self): self.search_manager = None self.channel_manager = None + self.gigachannel_manager = None self.video_server = None @@ -358,7 +360,7 @@ def load_ipv8_overlays(self): from Tribler.community.gigachannel.sync_strategy import SyncChannels community_cls = GigaChannelTestnetCommunity if self.session.config.get_testnet() else GigaChannelCommunity - self.gigachannel_community = community_cls(peer, self.ipv8.endpoint, self.ipv8.network, self.session) + self.gigachannel_community = community_cls(peer, self.ipv8.endpoint, self.ipv8.network, self.mds) self.ipv8.overlays.append(self.gigachannel_community) @@ -518,6 +520,8 @@ def init(self): channels_dir = os.path.join(self.session.config.get_chant_channels_dir()) database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'metadata.db') self.mds = MetadataStore(database_path, channels_dir, self.session.trustchain_keypair) + self.gigachannel_manager = GigaChannelManager(self.session) + self.gigachannel_manager.start() self.session.set_download_states_callback(self.sesscb_states_callback) @@ -526,72 +530,6 @@ def init(self): self.initComplete = True - def on_channel_download_finished(self, download, channel_id, finished_deferred=None): - if download.get_channel_download(): - channel_dirname = os.path.join(self.session.lm.mds.channels_dir, download.get_def().get_name()) - self.mds.process_channel_dir(channel_dirname, channel_id) - if finished_deferred: - finished_deferred.callback(download) - - @db_session - def update_channel(self, payload): - """ - We received some channel metadata, possibly over the network. - Validate the signature, update the local metadata store and start downloading this channel if needed. - :param payload: The channel metadata, in serialized form. - """ - if not payload.has_valid_signature(): - raise InvalidSignatureException("The signature of the channel metadata is invalid.") - - channel = self.mds.ChannelMetadata.get_channel_with_id(payload.public_key) - if channel: - if float2time(payload.timestamp) > channel.timestamp: - # Update the channel that is already there. - self._logger.info("Updating channel metadata %s ts %s->%s", str(channel.public_key).encode("hex"), - str(channel.timestamp), str(float2time(payload.timestamp))) - channel.set(**ChannelMetadataPayload.to_dict(payload)) - else: - # Add new channel object to DB - channel = self.mds.ChannelMetadata.from_payload(payload) - channel.subscribed = True - - if channel.version > channel.local_version: - self._logger.info("Downloading new channel version %s ver %i->%i", str(channel.public_key).encode("hex"), - channel.local_version, channel.version) - #TODO: handle the case where the local version is the same as the new one and is not seeded - return self.download_channel(channel) - - def download_channel(self, channel): - """ - Download a channel with a given infohash and title. - :param channel: The channel metadata ORM object. - """ - finished_deferred = Deferred() - - dcfg = DownloadStartupConfig() - dcfg.set_dest_dir(self.mds.channels_dir) - dcfg.set_channel_download(True) - tdef = TorrentDefNoMetainfo(infohash=str(channel.infohash), name=channel.title) - download = self.session.start_download_from_tdef(tdef, dcfg) - channel_id = channel.public_key - download.finished_callback = lambda dl: self.on_channel_download_finished(dl, channel_id, finished_deferred) - if download.get_state().get_status() == DLSTATUS_SEEDING and not download.finished_callback_already_called: - download.finished_callback_already_called = True - download.finished_callback(download) - return download, finished_deferred - - def updated_my_channel(self, new_torrent_path): - """ - Notify the core that we updated our channel. - :param new_torrent_path: path to the new torrent file - """ - # Start the new download - tdef = TorrentDef.load(new_torrent_path) - dcfg = DownloadStartupConfig() - dcfg.set_dest_dir(self.mds.channels_dir) - dcfg.set_channel_download(True) - self.add(tdef, dcfg) - def add(self, tdef, dscfg, pstate=None, setupDelay=0, hidden=False, share_mode=False, checkpoint_disabled=False): """ Called by any thread """ @@ -687,6 +625,10 @@ def get_downloads(self): with self.session_lock: return self.downloads.values() # copy, is mutable + def get_channel_downloads(self): + with self.session_lock: + return [download for download in self.downloads.values() if download.get_channel_download()] + def get_download(self, infohash): """ Called by any thread """ with self.session_lock: @@ -852,9 +794,6 @@ def sesscb_states_callback(self, states_list): if self.credit_mining_manager: self.credit_mining_manager.monitor_downloads(states_list) - if self.gigachannel_community: - self.gigachannel_community.update_states(states_list) - return [] # @@ -1018,6 +957,11 @@ def early_shutdown(self): yield self.channel_manager.shutdown() self.channel_manager = None + if self.gigachannel_manager: + self.session.notify_shutdown_state("Shutting down Gigachannel Manager...") + yield self.gigachannel_manager.shutdown() + self.gigachannel_manager = None + if self.search_manager: self.session.notify_shutdown_state("Shutting down Search Manager...") yield self.search_manager.shutdown() diff --git a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py b/Tribler/Core/CacheDB/SqliteCacheDBHandler.py index 0a603d0f6ee..073d35d5134 100644 --- a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py +++ b/Tribler/Core/CacheDB/SqliteCacheDBHandler.py @@ -16,12 +16,11 @@ from time import time from traceback import print_exc -from six import text_type from twisted.internet.task import LoopingCall +import Tribler.Core.Utilities.json_util as json from Tribler.Core.CacheDB.sqlitecachedb import bin2str, str2bin from Tribler.Core.TorrentDef import TorrentDef -import Tribler.Core.Utilities.json_util as json from Tribler.Core.Utilities.search_utils import split_into_keywords, filter_keywords from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url from Tribler.Core.Utilities.unicode import dunno2unicode @@ -75,8 +74,10 @@ def size(self): def getOne(self, value_name, where=None, conj=u"AND", **kw): return self._db.getOne(self.table_name, value_name, where=where, conj=conj, **kw) - def getAll(self, value_name, where=None, group_by=None, having=None, order_by=None, limit=None, offset=None, conj=u"AND", **kw): - return self._db.getAll(self.table_name, value_name, where=where, group_by=group_by, having=having, order_by=order_by, limit=limit, offset=offset, conj=conj, **kw) + def getAll(self, value_name, where=None, group_by=None, having=None, order_by=None, limit=None, offset=None, + conj=u"AND", **kw): + return self._db.getAll(self.table_name, value_name, where=where, group_by=group_by, having=having, + order_by=order_by, limit=limit, offset=offset, conj=conj, **kw) class PeerDBHandler(BasicDBHandler): @@ -458,6 +459,7 @@ def _indexTorrent(self, torrent_id, swarmname, files): if len(filenames) > 1000: def popSort(a, b): return filedict[a] - filedict[b] + filenames.sort(cmp=popSort, reverse=True) filenames = filenames[:1000] @@ -594,7 +596,8 @@ def on_search_response(self, torrents): tid = infohash_tid.get(infohash, None) if tid: # we know this torrent - if tid not in tid_collected and swarmname != tid_name.get(tid, ''): # if not collected and name not equal then do fullupdate + if tid not in tid_collected and swarmname != tid_name.get(tid, + ''): # if not collected and name not equal then do fullupdate update.append((swarmname, length, nrfiles, category, creation_date, infohash, status, tid)) to_be_indexed.append((tid, swarmname)) @@ -666,8 +669,8 @@ def addTorrentTrackerMappingInBatch(self, torrent_id, tracker_list): self.session.lm.tracker_manager.add_tracker(tracker) # update torrent-tracker mapping - sql = 'INSERT OR IGNORE INTO TorrentTrackerMapping(torrent_id, tracker_id)'\ - + ' VALUES(?, (SELECT tracker_id FROM TrackerInfo WHERE tracker = ?))' + sql = 'INSERT OR IGNORE INTO TorrentTrackerMapping(torrent_id, tracker_id)' \ + + ' VALUES(?, (SELECT tracker_id FROM TrackerInfo WHERE tracker = ?))' new_mapping_list = [(torrent_id, tracker) for tracker in tracker_list] if new_mapping_list: self._db.executemany(sql, new_mapping_list) @@ -718,9 +721,9 @@ def getTorrentsOnTracker(self, tracker, current_time, limit=30): return [str2bin(tinfo[0]) for tinfo in self._db.fetchall(sql, (tracker, current_time, limit))] def getTrackerListByTorrentID(self, torrent_id): - sql = 'SELECT TR.tracker FROM TrackerInfo TR, TorrentTrackerMapping MP'\ - + ' WHERE MP.torrent_id = ?'\ - + ' AND TR.tracker_id = MP.tracker_id' + sql = 'SELECT TR.tracker FROM TrackerInfo TR, TorrentTrackerMapping MP' \ + + ' WHERE MP.torrent_id = ?' \ + + ' AND TR.tracker_id = MP.tracker_id' tracker_list = self._db.fetchall(sql, (torrent_id,)) return [tracker[0] for tracker in tracker_list] @@ -744,9 +747,9 @@ def getTrackerInfoList(self): return tracker_info_list def updateTrackerInfo(self, args): - sql = 'UPDATE TrackerInfo SET'\ - + ' last_check = ?, failures = ?, is_alive = ?'\ - + ' WHERE tracker = ?' + sql = 'UPDATE TrackerInfo SET' \ + + ' last_check = ?, failures = ?, is_alive = ?' \ + + ' WHERE tracker = ?' self._db.executemany(sql, args) def getRecentlyAliveTrackers(self, limit=10): @@ -806,6 +809,7 @@ def fix_value(key): if result[key_index]: result[key_index] = str2bin(result[key_index]) results[i] = result + fix_value('infohash') return results @@ -912,17 +916,7 @@ def relevance_score_remote_torrent(self, torrent_name): """ if self.latest_matchinfo_torrent is None: return 0.0 - matchinfo, raw_keywords = self.latest_matchinfo_torrent - - # Make sure the strings are utf-8 encoded - keywords = [] - for keyword in raw_keywords: - if not isinstance(keyword, text_type): - keyword = keyword.decode('raw_unicode_escape') - keywords.append(keyword) - - if not isinstance(torrent_name, text_type): - torrent_name = torrent_name.decode('raw_unicode_escape') + matchinfo, keywords = self.latest_matchinfo_torrent num_phrases, num_cols, num_rows = unpack_from('III', matchinfo) unpack_str = 'I' * (3 * num_cols * num_phrases) @@ -939,7 +933,7 @@ def relevance_score_remote_torrent(self, torrent_name): score += inv_doc_freq * right_side return score - def search_in_local_torrents_db(self, query, keys=None): + def search_in_local_torrents_db(self, query, keys=None, first=0, last=None, family_filter=True): """ Search in the local database for torrents matching a specific query. This method also assigns a relevance score to each torrent, based on the name, files and file extensions. @@ -958,8 +952,12 @@ def search_in_local_torrents_db(self, query, keys=None): "FROM Torrent T, FullTextIndex " "LEFT OUTER JOIN _ChannelTorrents C ON T.torrent_id = C.torrent_id " "WHERE t.name IS NOT NULL AND t.torrent_id = FullTextIndex.rowid " - "AND C.deleted_at IS NULL AND FullTextIndex MATCH ?" - % keys_str, (" OR ".join(keywords),)) + "AND C.deleted_at IS NULL AND FullTextIndex MATCH ? " + "%s" + "LIMIT %i, %i" + % (keys_str, ("AND t.category != 'xxx' " if family_filter else ""), first, + last - first if last else first + 1000), + (" OR ".join(keywords),)) for result in results: result = list(result) # We convert the result to a mutable list since we have to decode the infohash @@ -1083,7 +1081,6 @@ def searchNames(self, kws, local=True, keys=None, doSort=True): elif infohash not in result_dict: result_dict[infohash] = result - # step 2, fix all dict fields dont_sort_list = [] results = [list(result) for result in result_dict.values()] @@ -1128,6 +1125,7 @@ def searchNames(self, kws, local=True, keys=None, doSort=True): def compare(a, b): return cmp(a[num_seeders_index], b[num_seeders_index]) + results.sort(compare, reverse=True) for index, result in dont_sort_list: @@ -1482,7 +1480,8 @@ def _get_my_dispersy_cid(self): from Tribler.community.channel.community import ChannelCommunity for community in self.session.lm.dispersy.get_communities(): - if isinstance(community, ChannelCommunity) and community.master_member and community.master_member.private_key: + if isinstance(community, + ChannelCommunity) and community.master_member and community.master_member.private_key: self.my_dispersy_cid = community.cid break @@ -2037,7 +2036,8 @@ def getTorrentFromChannelTorrentId(self, channeltorrent_id, keys): else: return self.__fixTorrent(keys, result) - def getTorrentsFromChannelId(self, channel_id, isDispersy, keys, limit=None): + def getTorrentsFromChannelId(self, channel_id, isDispersy, keys, limit=None, first=0, last=None): + last = last or limit if isDispersy: sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents WHERE Torrent.torrent_id = ChannelTorrents.torrent_id""" @@ -2045,12 +2045,14 @@ def getTorrentsFromChannelId(self, channel_id, isDispersy, keys, limit=None): sql = "SELECT " + ", ".join(keys) + """ FROM CollectedTorrent as Torrent, ChannelTorrents WHERE Torrent.torrent_id = ChannelTorrents.torrent_id""" + sql += " AND Torrent.name IS NOT NULL " + if channel_id: sql += " AND channel_id = ?" sql += " ORDER BY time_stamp DESC" - if limit: - sql += " LIMIT %d" % limit + if limit or last or first: + sql = sql + " LIMIT %i, %i" % (first, last - first if last else first + 1000) if channel_id: results = self._db.fetchall(sql, (channel_id,)) @@ -2213,7 +2215,7 @@ def getPlaylistsForTorrents(self, torrent_ids, keys): sql = "SELECT channeltorrent_id, " + ", ".join(keys) + \ ", count(DISTINCT channeltorrent_id) FROM Playlists, PlaylistTorrents " + \ "WHERE Playlists.id = PlaylistTorrents.playlist_id AND channeltorrent_id IN (" + \ - torrent_ids + ") GROUP BY Playlists.id" + torrent_ids + ") GROUP BY Playlists.id" return self._db.fetchall(sql) def __fixTorrent(self, keys, torrent): @@ -2227,6 +2229,7 @@ def fix_value(key, torrent): key_index = keys.index(key) if torrent[key_index]: torrent[key_index] = str2bin(torrent[key_index]) + if torrent: torrent = list(torrent) fix_value('infohash', torrent) @@ -2241,6 +2244,7 @@ def fix_value(key): if result[key_index]: result[key_index] = str2bin(result[key_index]) results[i] = result + fix_value('infohash') return results @@ -2370,21 +2374,34 @@ def calculate_score_channel(keywords, channel_name, channel_description): # and 20% on the matching in the channel description. return 0.8 * scores[0] + 0.2 * scores[1] - def search_in_local_channels_db(self, query): + def search_in_local_channels_db(self, query, first=0, last=None, count=False, chan_size_limit=3): """ Searches for matching channels against a given query in the database. """ search_results = [] keywords = split_into_keywords(query, to_filter_stopwords=True) - sql = "SELECT id, dispersy_cid, name, description, nr_torrents, nr_favorite, nr_spam, modified " \ - "FROM Channels WHERE " + if count: + sql = "SELECT COUNT (*) FROM (SELECT null " + else: + sql = "SELECT id, dispersy_cid, name, description, nr_torrents, nr_favorite, nr_spam, modified " + + sql += "FROM Channels WHERE" + if chan_size_limit: + sql += " nr_torrents >= %i and " % chan_size_limit + sql += " (" for _ in xrange(len(keywords)): sql += " name LIKE ? OR description LIKE ? OR " - sql = sql[:-4] + sql = sql[:-4] + " )" + sql += " LIMIT %i, %i" % (first, last - first if last else first + 1000) + if count: + sql += ")" bindings = list(chain.from_iterable(['%%%s%%' % keyword] * 2 for keyword in keywords)) results = self._db.fetchall(sql, bindings) + if count: + return results + my_votes = self.votecast_db.getMyVotes() for result in results: @@ -2417,8 +2434,8 @@ def getChannels(self, channel_ids): sql = "Select id, name, description, dispersy_cid, modified, " + \ "nr_torrents, nr_favorite, nr_spam FROM Channels " + \ "WHERE id IN ('" + \ - channel_ids + \ - "')" + channel_ids + \ + "')" return self._getChannels(sql) def getChannelsByCID(self, channel_cids): @@ -2428,13 +2445,20 @@ def getChannelsByCID(self, channel_cids): channel_cids = map(buffer, channel_cids) sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ "FROM Channels WHERE dispersy_cid IN (" + \ - parameters + \ - ")" + parameters + \ + ")" return self._getChannels(sql, channel_cids) - def getAllChannels(self): + def getAllChannelsCount(self): + sql = "SELECT COUNT (id) FROM CHANNELS" + return self._db.fetchall(sql) + + def getAllChannels(self, first=0, last=None): """ Returns all the channels """ - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam FROM Channels" + sql = ("Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam ") \ + + "FROM Channels " \ + + "WHERE nr_torrents >= 3 " \ + + "LIMIT %i, %i" % (first, last - first if last else first + 1000) return self._getChannels(sql) def getNewChannels(self, updated_since=0): @@ -2468,12 +2492,16 @@ def getMostPopularChannels(self, max_nr=20): "FROM Channels ORDER BY nr_favorite DESC, modified DESC LIMIT ?" return self._getChannels(sql, (max_nr,), includeSpam=False) - def getMySubscribedChannels(self, include_dispersy=False): - sql = "SELECT id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels, ChannelVotes " + \ - "WHERE Channels.id = ChannelVotes.channel_id AND voter_id ISNULL AND vote == 2" + def getMySubscribedChannels(self, include_dispersy=False, first=0, last=None, count=False): + sql = "SELECT COUNT(*) FROM (SELECT null " if count else "SELECT id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ + "FROM Channels, ChannelVotes " + \ + "WHERE Channels.id = ChannelVotes.channel_id AND voter_id ISNULL AND vote == 2" if not include_dispersy: - sql += " AND dispersy_cid == -1" + sql += " AND dispersy_cid == -1 " + + sql = sql + " LIMIT %i, %i" % (first, last - first if last else first + 1000) + if count: + sql += " )" return self._getChannels(sql) @@ -2495,7 +2523,7 @@ def _getChannels(self, sql, args=None, cmpF=None, includeSpam=True): name = "Unnamed channel" channels.append((id, str(dispersy_cid), name, description, nr_torrents, - nr_favorites, nr_spam, my_vote, modified, id == self._channel_id)) + nr_favorites, nr_spam, my_vote, modified, id == self._channel_id)) def channel_sort(a, b): # first compare local vote, spam -> return -1 diff --git a/Tribler/Core/CacheDB/sqlitecachedb.py b/Tribler/Core/CacheDB/sqlitecachedb.py index 34b539fcd03..e41256553df 100644 --- a/Tribler/Core/CacheDB/sqlitecachedb.py +++ b/Tribler/Core/CacheDB/sqlitecachedb.py @@ -3,6 +3,7 @@ Author(s): Jie Yang """ +from __future__ import absolute_import import logging import os diff --git a/Tribler/Core/Config/tribler_config.py b/Tribler/Core/Config/tribler_config.py index e133f227c47..94b14591e4c 100644 --- a/Tribler/Core/Config/tribler_config.py +++ b/Tribler/Core/Config/tribler_config.py @@ -138,12 +138,6 @@ def get_chant_channels_dir(self): path = self.config['chant']['channels_dir'] return path if os.path.isabs(path) else os.path.join(self.get_state_dir(), path) - def set_chant_channel_edit(self, value): - self.config['chant']['channel_edit'] = bool(value) - - def get_chant_channel_edit(self): - return self.config['chant']['channel_edit'] - # General def set_family_filter_enabled(self, value): self.config['general']['family_filter'] = bool(value) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 24c27c38727..3b5292762b0 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -5,8 +5,9 @@ from libtorrent import file_storage, add_files, create_torrent, set_piece_hashes, bencode, torrent_info from pony import orm -from pony.orm import db_session +from pony.orm import db_session, raw_sql, select +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, NEW, COMMITTED, PUBLIC_KEY_LEN from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload, CHANNEL_TORRENT from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelNameError from Tribler.pyipv8.ipv8.database import database_blob @@ -34,7 +35,6 @@ def entries_to_chunk(metadata_list, chunk_size, start_index=0): """ For efficiency reasons, this is deliberately written in C style :param metadata_list: the list of metadata to process. - :param limit: maximum size of a resulting chunk, in bytes. :param start_index: the index of the element of metadata_list from which the processing should start. :return: (chunk, last_entry_index) tuple, where chunk is the resulting chunk in string form and last_entry_index is the index of the element of the input list that was put into the chunk the last. @@ -46,7 +46,7 @@ def entries_to_chunk(metadata_list, chunk_size, start_index=0): offset = 0 last_entry_index = None for index, metadata in enumerate(metadata_list[start_index:], start_index): - blob = ''.join(metadata.serialized_delete() if metadata.deleted else metadata.serialized()) + blob = ''.join(metadata.serialized_delete() if metadata.status == TODELETE else metadata.serialized()) # Chunk size limit reached? if offset + len(blob) > chunk_size: break @@ -71,6 +71,7 @@ class ChannelMetadata(db.TorrentMetadata): local_version = orm.Optional(int, size=64, default=0) _payload_class = ChannelMetadataPayload _channels_dir = None + _category_filter = None _CHUNK_SIZE_LIMIT = 1 * 1024 * 1024 # We use 1MB chunks as a workaround for Python's lack of string pointers @db_session @@ -101,6 +102,11 @@ def process_channel_metadata_payload(cls, payload): channel.set(**payload.to_dict()) return channel + @classmethod + @db_session + def get_my_channel(cls): + return ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()) + @classmethod @db_session def create_channel(cls, title, description): @@ -133,7 +139,9 @@ def consolidate_channel_torrent(self): # We only remove mdblobs and leave the rest as it is if filename.endswith(BLOB_EXTENSION): os.unlink(file_path) - self.update_channel_torrent(self.contents_list) + for g in self.contents: + g.status = NEW + self.commit_channel_torrent() def update_channel_torrent(self, metadata_list): """ @@ -147,8 +155,8 @@ def update_channel_torrent(self, metadata_list): if not os.path.isdir(channel_dir): os.makedirs(channel_dir) - # Basically, a channel's version represents the count of unique entries it ever had. - # For a channel that never had deleted anything, it's version = len(contents) + # Basically, a channel's version number is the size of the set of all unique entries that were ever put + # into the channel. For a channel that never had anything deleted, version = len(contents) old_version = self.version index = 0 while index < len(metadata_list): @@ -160,7 +168,6 @@ def update_channel_torrent(self, metadata_list): new_version = self.version + len(metadata_list) - # Make torrent out of dir with metadata files start_ts = datetime.utcnow() torrent, infohash = create_torrent_from_dir(channel_dir, @@ -170,21 +177,11 @@ def update_channel_torrent(self, metadata_list): # a new metadata entry, the latter would still be listened as a staged entry. To account for this, # we store torrent_date with higher resolution. As libtorrent uses the moment of beginning of the torrent # creation as a source for 'creation date' for torrent, we sample it just before calling it. Then we select - # the larger of two timestamps. + # the larger of the two timestamps. torrent_date = datetime.utcfromtimestamp(torrent['creation date']) torrent_date_corrected = start_ts if start_ts > torrent_date else torrent_date - self.update_metadata(update_dict={"infohash": infohash, "version": new_version, - "torrent_date": torrent_date_corrected}) - - self.local_version = new_version - # Write the channel mdblob away - with open(os.path.join(self._channels_dir, self.dir_name + BLOB_EXTENSION), 'wb') as out_file: - out_file.write(''.join(self.serialized())) - - self._logger.info("Channel %s committed with %i new entries. New version is %i", - str(self.public_key).encode("hex"), len(metadata_list), new_version) - return infohash + return {"infohash": infohash, "version": new_version, "torrent_date": torrent_date_corrected} def commit_channel_torrent(self): """ @@ -193,15 +190,29 @@ def commit_channel_torrent(self): :return The new infohash, should be used to update the downloads """ new_infohash = None + md_list = self.staged_entries_list try: - new_infohash = self.update_channel_torrent(self.staged_entries_list) + update_dict = self.update_channel_torrent(md_list) except IOError: self._logger.error( "Error during channel torrent commit, not going to garbage collect the channel. Channel %s", str(self.public_key).encode("hex")) else: - # Clean up obsolete entries - self.garbage_collect() + self.update_metadata(update_dict) + self.local_version = self.version + # Change status of committed metadata and clean up obsolete TODELETE entries + for g in md_list: + if g.status == NEW: + g.status = COMMITTED + elif g.status == TODELETE: + g.delete() + + # Write the channel mdblob to disk + with open(os.path.join(self._channels_dir, self.dir_name + BLOB_EXTENSION), 'wb') as out_file: + out_file.write(''.join(self.serialized())) + + self._logger.info("Channel %s committed with %i new entries. New version is %i", + str(self.public_key).encode("hex"), len(md_list), update_dict['version']) return new_infohash @db_session @@ -223,57 +234,49 @@ def add_torrent_to_channel(self, tdef, extra_info): if self.has_torrent(tdef.get_infohash()): raise DuplicateTorrentFileError() + if extra_info: + tags = extra_info.get('description', '') + elif self._category_filter: + tags = self._category_filter.calculateCategory(tdef.metainfo, tdef.get_name_as_unicode()) + else: + tags = '' + torrent_metadata = db.TorrentMetadata.from_dict({ "infohash": tdef.get_infohash(), "title": tdef.get_name_as_unicode(), - "tags": extra_info.get('description', '') if extra_info else '', + "tags": tags, "size": tdef.get_length(), "torrent_date": datetime.fromtimestamp(tdef.get_creation_date()), - "tc_pointer": 0, "tracker_info": tdef.get_tracker() or '', - "public_key": self._my_key.pub().key_to_bin() + "status": NEW }) torrent_metadata.sign() + @property + def dirty(self): + return self.contents.where(lambda g: g.status == NEW or g.status == TODELETE).exists() + @property def contents(self): return db.TorrentMetadata.select(lambda g: g.public_key == self.public_key and g != self) @property def uncommitted_contents(self): - return (g for g in self.newer_entries if not g.deleted) - - @property - def committed_contents(self): - return (g for g in self.older_entries if not g.deleted) + return self.contents.where(lambda g: g.status == NEW) @property def deleted_contents(self): - return (g for g in self.contents if g.deleted) + return self.contents.where(lambda g: g.status == TODELETE) @property def dir_name(self): # Have to limit this to support Windows file path length limit - return str(self.public_key).encode('hex')[-CHANNEL_DIR_NAME_LENGTH:] - - @property - def newer_entries(self): - return db.Metadata.select( - lambda g: g.timestamp > self.torrent_date and g.public_key == self.public_key and g != self) - - @property - def older_entries(self): - return db.Metadata.select( - lambda g: g.timestamp < self.torrent_date and g.public_key == self.public_key and g != self) - - @db_session - def garbage_collect(self): - orm.delete(g for g in self.older_entries if g.deleted) + return str(self.public_key).encode('hex')[:CHANNEL_DIR_NAME_LENGTH] @property @db_session def staged_entries_list(self): - return list(self.deleted_contents) + list(self.newer_entries) + return list(self.deleted_contents) + list(self.uncommitted_contents) @property @db_session @@ -296,11 +299,29 @@ def delete_torrent_from_channel(self, infohash): torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) else: return False - if torrent_metadata.timestamp > self.torrent_date: + if torrent_metadata.status == NEW: # Uncommited metadata. Delete immediately torrent_metadata.delete() else: - torrent_metadata.deleted = True + torrent_metadata.status = TODELETE + return True + + @db_session + def cancel_torrent_deletion(self, infohash): + """ + Cancel pending removal of torrent marked for deletion. + :param infohash: The infohash of the torrent to act upon + :return True if deleteion cancelled, False if no MD with the given infohash found + """ + if self.has_torrent(infohash): + torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) + else: + return False + + # As any NEW metadata is deleted immediately, only COMMITTED -> TODELETE + # Therefore we restore the entry's status to COMMITTED + if torrent_metadata.status == TODELETE: + torrent_metadata.status = COMMITTED return True @classmethod @@ -313,6 +334,17 @@ def get_channel_with_id(cls, channel_id): """ return cls.get(public_key=database_blob(channel_id)) + @db_session + def drop_channel_contents(self): + """ + Remove all torrents from the channel + """ + # Immediately delete uncommitted metadata + self.uncommitted_contents.delete() + # Mark the rest as deleted + for g in self.contents: + g.status = TODELETE + @classmethod @db_session def get_channel_with_infohash(cls, infohash): @@ -320,14 +352,42 @@ def get_channel_with_infohash(cls, infohash): @classmethod @db_session - def get_random_channels(cls, limit): + def get_channel_with_dirname(cls, dirname): + # It is impossible to use LIKE queries on BLOBs, so we have to use comparisons + def extend_to_bitmask(txt): + return txt + "0" * (PUBLIC_KEY_LEN * 2 - CHANNEL_DIR_NAME_LENGTH) + + dirname_binmask_start = "x'" + extend_to_bitmask(dirname) + "'" + + binmask_plus_one = "%X" % (int(dirname, 16) + 1) + dirname_binmask_end = "x'" + extend_to_bitmask(binmask_plus_one) + "'" + + sql = "g.public_key >= " + dirname_binmask_start + " AND g.public_key < " + dirname_binmask_end + return orm.get(g for g in cls if raw_sql(sql)) + + @classmethod + @db_session + def get_random_subscribed_channels(cls, limit): """ - Fetch up to some limit of channels we are subscribed to. + Fetch up to some limit of torrents from this channel - :param limit: the maximum amount of channels to fetch + :param limit: the maximum amount of torrents to fetch :return: the subset of random channels we are subscribed to :rtype: list """ return db.ChannelMetadata.select(lambda g: g.subscribed).random(limit) + @db_session + def get_random_torrents(self, limit): + return self.contents.random(limit) + + @db_session + def remove_contents(self): + self.contents.delete() + + @classmethod + @db_session + def get_updated_channels(cls): + return select(g for g in cls if g.subscribed and (g.local_version < g.version)) + return ChannelMetadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index 857f3a2aa6d..05000986b9b 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -1,13 +1,25 @@ from __future__ import absolute_import +from binascii import hexlify from datetime import datetime from pony import orm from Tribler.Core.Modules.MetadataStore.serialization import MetadataPayload, DeletedMetadataPayload, TYPELESS, DELETED +from Tribler.Core.exceptions import InvalidSignatureException from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto +# Metadata, torrents and channel statuses +NEW = 0 +TODELETE = 1 +COMMITTED = 2 +JUST_RECEIVED = 3 +UPDATE_AVAILABLE = 4 +PREVIEW_UPDATE_AVAILABLE = 5 + +PUBLIC_KEY_LEN = 74 + def define_binding(db): class Metadata(db.Entity): @@ -18,18 +30,56 @@ class Metadata(db.Entity): signature = orm.Optional(database_blob) timestamp = orm.Optional(datetime, default=datetime.utcnow) tc_pointer = orm.Optional(int, size=64, default=0) - public_key = orm.Optional(database_blob, default='\x00' * 74) + public_key = orm.Optional(database_blob, default='\x00' * PUBLIC_KEY_LEN) addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) - deleted = orm.Optional(bool, default=False) + status = orm.Optional(int, default=COMMITTED) _payload_class = MetadataPayload _my_key = None _logger = None def __init__(self, *args, **kwargs): + + # Special "sign_with" argument given, sign with it + private_key_override = None + if "sign_with" in kwargs: + kwargs["public_key"] = database_blob(kwargs["sign_with"].pub().key_to_bin()) + private_key_override = kwargs["sign_with"] + kwargs.pop("sign_with") + + # FIXME: potential race condition here? To avoid it, generate the signature _before_ calling "super" super(Metadata, self).__init__(*args, **kwargs) - # If no key/signature given, sign with our own key. - if "public_key" not in kwargs or (kwargs["public_key"] == self._my_key and "signature" not in kwargs): + + if private_key_override: + self.sign(private_key_override) + return + # No key/signature given, sign with our own key. + elif ("signature" not in kwargs) and \ + (("public_key" not in kwargs) or ( + kwargs["public_key"] == database_blob(self._my_key.pub().key_to_bin()))): self.sign(self._my_key) + return + + # Key/signature given, check them for correctness + elif ("public_key" in kwargs) and ("signature" in kwargs) and self.has_valid_signature(): + return + + # Otherwise, something is wrong + raise InvalidSignatureException( + ("Attempted to create %s object with invalid signature/PK: " % str(self.__class__.__name__)) + + (hexlify(self.signature) if self.signature else "empty signature ") + " / " + + (hexlify(self.public_key) if self.public_key else " empty PK")) + + """ + # This is a way to manually define Pony entity default attributes in case we really + have to generate the signature before creating the object + from pony.orm.core import DEFAULT + def generate_dict_from_pony_args(cls, **kwargs): + d = {} + for attr in cls._attrs_: + val = kwargs.get(attr.name, DEFAULT) + d[attr.name] = attr.validate(val, entity=cls) + return d + """ def _serialized(self, key=None): """ diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index c9aeaaf0f44..a4475de85d0 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -1,9 +1,8 @@ from __future__ import absolute_import - from datetime import datetime from pony import orm -from pony.orm import db_session +from pony.orm import db_session, raw_sql from Tribler.Core.Modules.MetadataStore.serialization import TorrentMetadataPayload, REGULAR_TORRENT from Tribler.pyipv8.ipv8.database import database_blob @@ -39,15 +38,15 @@ def search_keyword(cls, query, entry_type=None, lim=100): else: query = "\"" + query + "\"" - metadata_type = entry_type or cls._discriminator_ - sql_search_fts = "metadata_type = %d AND rowid IN (SELECT rowid FROM FtsIndex WHERE " \ - "FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT %d)" % (metadata_type, lim) - return cls.select(lambda x: orm.raw_sql(sql_search_fts))[:] + fts_ids = raw_sql( + "SELECT rowid FROM FtsIndex WHERE FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT %d" % lim) + return cls.select(lambda g: g.rowid in fts_ids) + @classmethod def get_auto_complete_terms(cls, keyword, max_terms, limit=100): with db_session: - result = cls.search_keyword(keyword + "*", lim=limit) + result = cls.search_keyword(keyword + "*", lim=limit)[:] titles = [g.title.lower() for g in result] # Copy-pasted from the old DBHandler (almost) completely diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index 6ed8cdd4bc2..6f1366b82d3 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -1,6 +1,7 @@ from __future__ import absolute_import, division import struct +from binascii import hexlify from datetime import datetime, timedelta from Tribler.Core.exceptions import InvalidSignatureException @@ -9,7 +10,6 @@ from Tribler.pyipv8.ipv8.messaging.payload import Payload from Tribler.pyipv8.ipv8.messaging.serialization import default_serializer - EPOCH = datetime(1970, 1, 1) INFOHASH_SIZE = 20 # bytes @@ -102,7 +102,8 @@ def __init__(self, metadata_type, public_key, timestamp, tc_pointer, **kwargs): def has_valid_signature(self): sig_data = default_serializer.pack_multiple(self.to_pack_list())[0] - return default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(self.public_key), sig_data, self.signature) + return default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(self.public_key), sig_data, + self.signature) def to_pack_list(self): data = [('I', self.metadata_type), @@ -146,7 +147,15 @@ def _serialized(self, key=None): raise KeysMismatchException(self.public_key, str(key.pub().key_to_bin())) serialized_data = default_serializer.pack_multiple(self.to_pack_list())[0] - signature = default_eccrypto.create_signature(key, serialized_data) if key else self.signature + if key: + signature = default_eccrypto.create_signature(key, serialized_data) + + # This check ensures that an entry with a wrong signature will not proliferate further + elif default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(self.public_key), serialized_data, + self.signature): + signature = self.signature + else: + raise InvalidSignatureException(hexlify(self.signature)) return str(serialized_data), str(signature) def serialized(self, key=None): @@ -169,17 +178,17 @@ def __init__(self, metadata_type, public_key, timestamp, tc_pointer, infohash, s super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, timestamp, tc_pointer, **kwargs) self.infohash = str(infohash) self.size = size - self.title = title.encode("utf-8") - self.tags = tags.encode("utf-8") - self.tracker_info = tracker_info.encode("utf-8") + self.title = title.decode('utf-8') if type(title) == str else title + self.tags = tags.decode('utf-8') if type(tags) == str else tags + self.tracker_info = tracker_info.decode('utf-8') if type(tracker_info) == str else tracker_info def to_pack_list(self): data = super(TorrentMetadataPayload, self).to_pack_list() data.append(('20s', self.infohash)) data.append(('Q', self.size)) - data.append(('varlenI', self.title)) - data.append(('varlenI', self.tags)) - data.append(('varlenI', self.tracker_info)) + data.append(('varlenI', self.title.encode('utf-8'))) + data.append(('varlenI', self.tags.encode('utf-8'))) + data.append(('varlenI', self.tracker_info.encode('utf-8'))) return data @classmethod diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 6b0476ead83..570de8edd15 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -1,18 +1,20 @@ +from __future__ import absolute_import + import logging import os from pony import orm from pony.orm import db_session +from Tribler.Core.Category.Category import Category from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ - CHANNEL_TORRENT, DELETED + CHANNEL_TORRENT, DELETED, float2time, ChannelMetadataPayload # This table should never be used from ORM directly. # It is created as a VIRTUAL table by raw SQL and # maintained by SQL triggers. from Tribler.Core.exceptions import InvalidSignatureException -from Tribler.pyipv8.ipv8.messaging.serialization import Serializer sql_create_fts_table = """ CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING FTS5 @@ -71,6 +73,9 @@ def __init__(self, db_filename, channels_dir, my_key): self.ChannelMetadata._channels_dir = channels_dir self.Metadata._logger = self._logger # Use Store-level logger for every ORM-based class + # TODO: move Category Filter into a module-level global stateless object (i.e. make it a singleton) + self.ChannelMetadata._category_filter = Category() + self._db.bind(provider='sqlite', filename=db_filename, create_db=create_db) if create_db: with db_session: @@ -158,7 +163,33 @@ def process_payload(self, payload): elif payload.metadata_type == REGULAR_TORRENT: return self.TorrentMetadata.from_payload(payload) elif payload.metadata_type == CHANNEL_TORRENT: - return self.ChannelMetadata.from_payload(payload) + return self.update_channel_info(payload) + + @db_session + def update_channel_info(self, payload): + """ + We received some channel metadata, possibly over the network. + Validate the signature, update the local metadata store and put in at the beginning of the download queue + if necessary. + :param payload: The channel metadata, in serialized form. + """ + + channel = self.ChannelMetadata.get_channel_with_id(payload.public_key) + if channel: + if float2time(payload.timestamp) > channel.timestamp: + # Update the channel that is already there. + self._logger.info("Updating channel metadata %s ts %s->%s", str(channel.public_key).encode("hex"), + str(channel.timestamp), str(float2time(payload.timestamp))) + channel.set(**ChannelMetadataPayload.to_dict(payload)) + else: + # Add new channel object to DB + channel = self.ChannelMetadata.from_payload(payload) + + """ + if channel.version > channel.local_version: + #TODO: handle the case where the local version is the same as the new one and is not seeded + """ + return channel @db_session def get_my_channel(self): diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py new file mode 100644 index 00000000000..dfbda891fd2 --- /dev/null +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -0,0 +1,117 @@ +import os +from binascii import hexlify + +from pony.orm import db_session +from twisted.internet.defer import Deferred +from twisted.internet.task import LoopingCall + +from Tribler.Core.DownloadConfig import DownloadStartupConfig +from Tribler.Core.TorrentDef import TorrentDefNoMetainfo, TorrentDef +from Tribler.Core.simpledefs import DLSTATUS_SEEDING +from Tribler.pyipv8.ipv8.taskmanager import TaskManager + + +class GigaChannelManager(TaskManager): + """ + This class represents the main manager for gigachannels. + It provides methods to manage channels, download new channels or remove existing ones. + """ + + def __init__(self, session): + super(GigaChannelManager, self).__init__() + self.session = session + + def start(self): + """ + The Metadata Store checks the database at regular intervals to see if new channels are available for preview + or subscribed channels require updating. + """ + queue_check_interval = 2.0 # seconds + self.register_task("Process channels download queue", + LoopingCall(self.check_channels_updates)).start(queue_check_interval) + + def shutdown(self): + """ + Stop the gigachannel manager. + """ + self.shutdown_task_manager() + + def check_channels_updates(self): + """ + Check whether there are channels that are updated. If so, download the new version of the channel. + """ + with db_session: + channels_queue = list(self.session.lm.mds.ChannelMetadata.get_updated_channels()) + + for channel in channels_queue: + if not self.session.has_download(hexlify(str(channel.infohash))): + self._logger.info("Downloading new channel version %s ver %i->%i", + str(channel.public_key).encode("hex"), + channel.local_version, channel.version) + self.download_channel(channel) + + def on_channel_download_finished(self, download, channel_id, finished_deferred=None): + """ + We have finished with downloading a channel. + :param download: The channel download itself. + :param channel_id: The ID of the channel. + :param finished_deferred: An optional deferred that should fire if the channel download has finished. + """ + if download.get_channel_download(): + channel_dirname = os.path.join(self.session.lm.mds.channels_dir, download.get_def().get_name()) + self.session.lm.mds.process_channel_dir(channel_dirname, channel_id) + if finished_deferred: + finished_deferred.callback(download) + + @db_session + def remove_channel(self, channel): + """ + Remove a channel from your local database/download list. + :param channel: The channel to remove. + """ + channel.subscribed = False + channel.remove_contents() + channel.local_version = 0 + + # Remove all stuff matching the channel dir name / public key / torrent title + remove_list = [d for d in self.session.lm.get_channel_downloads() if d.tdef.get_name_utf8() == channel.dir_name] + + def _on_remove_failure(failure): + self._logger.error("Error when removing the channel download: %s", failure) + + for i, d in enumerate(remove_list): + deferred = self.session.remove_download(d, remove_content=True) + deferred.addErrback(_on_remove_failure) + self.register_task(u'remove_channel' + d.tdef.get_name_utf8() + u'-' + hexlify(d.tdef.get_infohash()) + + u'-' + str(i), deferred) + + def download_channel(self, channel): + """ + Download a channel with a given infohash and title. + :param channel: The channel metadata ORM object. + """ + finished_deferred = Deferred() + + dcfg = DownloadStartupConfig() + dcfg.set_dest_dir(self.session.lm.mds.channels_dir) + dcfg.set_channel_download(True) + tdef = TorrentDefNoMetainfo(infohash=str(channel.infohash), name=channel.dir_name) + download = self.session.start_download_from_tdef(tdef, dcfg) + channel_id = channel.public_key + #TODO: add errbacks here! + download.finished_callback = lambda dl: self.on_channel_download_finished(dl, channel_id, finished_deferred) + if download.get_state().get_status() == DLSTATUS_SEEDING and not download.finished_callback_already_called: + download.finished_callback_already_called = True + download.finished_callback(download) + return download, finished_deferred + + def updated_my_channel(self, new_torrent_path): + """ + Notify the core that we updated our channel. + :param new_torrent_path: path to the new torrent file + """ + tdef = TorrentDef.load(new_torrent_path) + dcfg = DownloadStartupConfig() + dcfg.set_dest_dir(self.session.lm.mds.channels_dir) + dcfg.set_channel_download(True) + self.session.lm.add(tdef, dcfg) diff --git a/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py index 938b0fc1abb..76a54ee2f07 100644 --- a/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py +++ b/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py @@ -1,73 +1,24 @@ +from __future__ import absolute_import + from pony.orm import db_session from twisted.web import http +import Tribler.Core.Utilities.json_util as json from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint from Tribler.Core.Modules.restapi.channels.channels_playlists_endpoint import ChannelsPlaylistsEndpoint from Tribler.Core.Modules.restapi.channels.channels_rss_endpoint import ChannelsRssFeedsEndpoint, \ ChannelsRecheckFeedsEndpoint from Tribler.Core.Modules.restapi.channels.channels_torrents_endpoint import ChannelsTorrentsEndpoint -from Tribler.Core.Modules.restapi.util import convert_db_channel_to_json, convert_channel_metadata_to_tuple -from Tribler.Core.exceptions import DuplicateChannelNameError -import Tribler.Core.Utilities.json_util as json class ChannelsDiscoveredEndpoint(BaseChannelsEndpoint): """ This class is responsible for requests regarding the discovered channels. """ + def getChild(self, path, request): return ChannelsDiscoveredSpecificEndpoint(self.session, path) - @db_session - def render_GET(self, _): - """ - .. http:get:: /channels/discovered - - A GET request to this endpoint returns all channels discovered in Tribler. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/discovered - - **Example response**: - - .. sourcecode:: javascript - - { - "channels": [{ - "id": 3, - "dispersy_cid": "da69aaad39ccf468aba2ab9177d5f8d8160135e6", - "name": "My fancy channel", - "description": "A description of this fancy channel", - "subscribed": False, - "votes": 23, - "torrents": 3, - "spam": 5, - "modified": 14598395, - "can_edit": True - }, ...] - } - """ - all_channels_db = self.channel_db_handler.getAllChannels() - - if self.session.config.get_chant_enabled(): - chant_channels = list(self.session.lm.mds.ChannelMetadata.select()) - for chant_channel in chant_channels: - all_channels_db.append(convert_channel_metadata_to_tuple(chant_channel)) - - results_json = [] - for channel in all_channels_db: - channel_json = convert_db_channel_to_json(channel) - if self.session.config.get_family_filter_enabled() and \ - self.session.lm.category.xxx_filter.isXXX(channel_json['name']): - continue - - results_json.append(channel_json) - - return json.dumps({"channels": results_json}) - def render_PUT(self, request): """ .. http:put:: /channels/discovered @@ -101,35 +52,21 @@ def render_PUT(self, request): if 'description' not in parameters or len(parameters['description']) == 0: description = u'' else: - description = unicode(parameters['description'][0], 'utf-8') - - if self.session.config.get_chant_channel_edit(): - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - - # Do not allow to add a channel twice - if self.session.lm.mds.get_my_channel(): - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "channel already exists"}) - - title = unicode(parameters['name'][0], 'utf-8') - self.session.lm.mds.ChannelMetadata.create_channel(title, description) - return json.dumps({ - "added": str(my_channel_id).encode("hex"), - }) - - if 'mode' not in parameters or len(parameters['mode']) == 0: - # By default, the mode of the new channel is closed. - mode = u'closed' - else: - mode = unicode(parameters['mode'][0], 'utf-8') + description = str(parameters['description'][0]).encode('utf-8') + + my_key = self.session.trustchain_keypair + my_channel_id = my_key.pub().key_to_bin() - try: - channel_id = self.session.create_channel(unicode(parameters['name'][0], 'utf-8'), description, mode) - except DuplicateChannelNameError as ex: - return BaseChannelsEndpoint.return_500(self, request, ex) + # Do not allow to add a channel twice + if self.session.lm.mds.get_my_channel(): + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "channel already exists"}) - return json.dumps({"added": channel_id}) + title = str(parameters['name'][0]).encode('utf-8') + self.session.lm.mds.ChannelMetadata.create_channel(title, description) + return json.dumps({ + "added": str(my_channel_id).encode("hex"), + }) class ChannelsDiscoveredSpecificEndpoint(BaseChannelsEndpoint): diff --git a/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py index 2b0b979eb03..72b20a0c630 100644 --- a/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py +++ b/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py @@ -127,36 +127,18 @@ def render_PUT(self, request): """ request.setHeader('Content-Type', 'text/json') - if self.session.config.get_chant_channel_edit(): - with db_session: - channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.cid)) - if not channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": CHANNEL_NOT_FOUND}) - - if channel.subscribed: - request.setResponseCode(http.CONFLICT) - return json.dumps({"error": ALREADY_SUBSCRIBED_RESPONSE_MSG}) - channel.subscribed = True - - return json.dumps({"subscribed": True}) - - channel_info = self.get_channel_from_db(self.cid) - - if channel_info is not None and channel_info[7] == VOTE_SUBSCRIBE: - request.setResponseCode(http.CONFLICT) - return json.dumps({"error": ALREADY_SUBSCRIBED_RESPONSE_MSG}) - - def on_vote_done(_): - request.write(json.dumps({"subscribed": True})) - request.finish() + with db_session: + channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.cid)) + if not channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": CHANNEL_NOT_FOUND}) - def on_vote_error(failure): - request.processingFailed(failure) + if channel.subscribed: + request.setResponseCode(http.CONFLICT) + return json.dumps({"error": ALREADY_SUBSCRIBED_RESPONSE_MSG}) + channel.subscribed = True - self.vote_for_channel(self.cid, VOTE_SUBSCRIBE).addCallback(on_vote_done).addErrback(on_vote_error) - - return NOT_DONE_YET + return json.dumps({"subscribed": True}) def render_DELETE(self, request): """ @@ -181,6 +163,17 @@ def render_DELETE(self, request): :statuscode 404: if you are not subscribed to the specified channel. """ request.setHeader('Content-Type', 'text/json') + + if len(self.cid) == 74: + with db_session: + channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.cid)) + if not channel: + return ChannelsModifySubscriptionEndpoint.return_404(request) + elif not channel.subscribed: + return ChannelsModifySubscriptionEndpoint.return_404(request, message=NOT_SUBSCRIBED_RESPONSE_MSG) + self.session.lm.gigachannel_manager.remove_channel(channel) + return json.dumps({"unsubscribed": True}) + channel_info = self.get_channel_from_db(self.cid) if channel_info is None: return ChannelsModifySubscriptionEndpoint.return_404(request) diff --git a/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py index 42738066aa2..7f1520e98c9 100644 --- a/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py +++ b/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py @@ -1,9 +1,14 @@ +from __future__ import absolute_import + import base64 -from twisted.web.error import SchemeNotSupported +import os +import sys +from binascii import unhexlify -from pony.orm import db_session +from pony.orm import db_session, desc from twisted.internet.defer import Deferred from twisted.web import http +from twisted.web.error import SchemeNotSupported from twisted.web.server import NOT_DONE_YET import Tribler.Core.Utilities.json_util as json @@ -12,12 +17,17 @@ from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.utilities import http_get from Tribler.Core.exceptions import DuplicateTorrentFileError, HttpError -from TriblerGUI.defs import UNCOMMITTED, TODELETE, COMMITTED UNKNOWN_TORRENT_MSG = "this torrent is not found in the specified channel" UNKNOWN_COMMUNITY_MSG = "the community for the specified channel cannot be found" +def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i:i + n] + + class ChannelsTorrentsEndpoint(BaseChannelsEndpoint): """ This class is responsible for managing requests regarding torrents in a channel. @@ -31,100 +41,39 @@ def __init__(self, session, cid): def getChild(self, path, request): return ChannelModifyTorrentEndpoint(self.session, self.cid, path) - def render_GET(self, request): + @db_session + def render_PUT(self, request): """ - .. http:get:: /channels/discovered/(string: channelid)/torrents + .. http:put:: /channels/discovered/(string: channelid)/torrents - A GET request to this endpoint returns all discovered torrents in a specific channel. The size of the torrent is - in number of bytes. The last_tracker_check value will be 0 if we did not check the tracker state of the torrent - yet. Optionally, we can disable the family filter for this particular request by passing the following flag: - - disable_filter: whether the family filter should be disabled for this request (1 = disabled) + Add a torrent file to your own channel. Returns error 500 if something is wrong with the torrent file + and DuplicateTorrentFileError if already added to your channel. The torrent data is passed as base-64 encoded + string. The description is optional. + + Option torrents_dir adds all .torrent files from a chosen directory + Option recursive enables recursive scanning of the chosen directory for .torrent files **Example request**: .. sourcecode:: none - curl -X GET http://localhost:8085/channels/discovered/da69aaad39ccf468aba2ab9177d5f8d8160135e6/torrents + curl -X PUT http://localhost:8085/channels/discovered/abcd/torrents + --data "torrent=...&description=funny video" **Example response**: .. sourcecode:: javascript { - "torrents": [{ - "id": 4, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779", - "name": "Ubuntu-16.04-desktop-amd64", - "size": 8592385, - "category": "other", - "num_seeders": 42, - "num_leechers": 184, - "last_tracker_check": 1463176959 - }, ...] + "added": True } - :statuscode 404: if the specified channel cannot be found. - """ - chant_dirty = False - if self.is_chant_channel: - with db_session: - channel = self.session.lm.mds.ChannelMetadata.get(public_key=self.cid) - if channel: - if channel == self.session.lm.mds.get_my_channel(): - # That's our channel, it gets special treatment - uncommitted = [convert_torrent_metadata_to_tuple(x, UNCOMMITTED) for x in - list(channel.uncommitted_contents)] - deleted = [convert_torrent_metadata_to_tuple(x, TODELETE) for x in - list(channel.deleted_contents)] - committed = [convert_torrent_metadata_to_tuple(x, COMMITTED) for x in - list(channel.committed_contents)] - results_local_torrents_channel = uncommitted + deleted + committed - chant_dirty = bool(uncommitted + deleted) - else: - results_local_torrents_channel = map(convert_torrent_metadata_to_tuple, - list(channel.contents)) - else: - return ChannelsTorrentsEndpoint.return_404(request) - else: - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsTorrentsEndpoint.return_404(request) - - torrent_db_columns = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', - 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.inserted'] - results_local_torrents_channel = self.channel_db_handler\ - .getTorrentsFromChannelId(channel_info[0], True, torrent_db_columns) - - should_filter = self.session.config.get_family_filter_enabled() - if 'disable_filter' in request.args and len(request.args['disable_filter']) > 0 \ - and request.args['disable_filter'][0] == "1": - should_filter = False - - results_json = [] - for torrent_result in results_local_torrents_channel: - torrent_json = convert_db_torrent_to_json(torrent_result) - if torrent_json['name'] is None or (should_filter and torrent_json['category'] == 'xxx'): - continue - - results_json.append(torrent_json) - - return json.dumps({"torrents": results_json, "chant_dirty": chant_dirty}) - - @db_session - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/torrents - - Add a torrent file to your own channel. Returns error 500 if something is wrong with the torrent file - and DuplicateTorrentFileError if already added to your channel. The torrent data is passed as base-64 encoded - string. The description is optional. - **Example request**: .. sourcecode:: none - curl -X PUT http://localhost:8085/channels/discovered/abcd/torrents - --data "torrent=...&description=funny video" + curl -X PUT http://localhost:8085/channels/discovered/abcd/torrents?torrents_dir=some_dir&recursive=1 + --data "" **Example response**: @@ -132,6 +81,7 @@ def render_PUT(self, request): { "added": True + "num_added_torrents": 13 } :statuscode 404: if your channel does not exist. @@ -156,6 +106,52 @@ def render_PUT(self, request): parameters = http.parse_qs(request.content.read(), 1) + torrents_dir = None + if 'torrents_dir' in parameters and parameters['torrents_dir'] > 0: + torrents_dir = parameters['torrents_dir'][0] + if not os.path.isabs(torrents_dir): + request.setResponseCode(http.BAD_REQUEST) + + recursive = False + if 'recursive' in parameters and parameters['recursive'] > 0: + recursive = parameters['recursive'][0] + if not torrents_dir: + request.setResponseCode(http.BAD_REQUEST) + + if torrents_dir: + torrents_list = [] + errors_list = [] + filename_generator = None + + if recursive: + def rec_gen(): + for root, _, filenames in os.walk(torrents_dir): + for fn in filenames: + yield os.path.join(root, fn) + + filename_generator = rec_gen() + else: + filename_generator = os.listdir(torrents_dir) + + # Build list of .torrents to process + for f in filename_generator: + filepath = os.path.join(torrents_dir, f) + filename = str(filepath) if sys.platform == 'win32' else filepath.decode('utf-8') + if os.path.isfile(filepath) and filename.endswith(u'.torrent'): + torrents_list.append(filepath) + + for chunk in chunks(torrents_list, 100): # 100 is a reasonable chunk size for commits + with db_session: + for f in chunk: + try: + channel.add_torrent_to_channel(TorrentDef.load(f), {}) + except DuplicateTorrentFileError: + pass + except: + errors_list.append(f) + + return json.dumps({"added": len(torrents_list), "errors": errors_list}) + if 'torrent' not in parameters or len(parameters['torrent']) == 0: request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "torrent parameter missing"}) @@ -230,7 +226,7 @@ def render_PUT(self, request): if self.is_chant_channel: if my_channel_id != self.cid: request.setResponseCode(http.NOT_ALLOWED) - return json.dumps({"error": "you can only add torrents to your own chant channel"}) + return json.dumps({"error": "you can only add torrents to your own channel"}) channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) else: channel = self.get_channel_from_db(self.cid) @@ -296,9 +292,12 @@ def _on_timeout(_): def render_DELETE(self, request): """ - .. http:delete:: /channels/discovered/(string: channelid)/torrents/(string: comma separated torrent infohashes) + .. http:delete:: /channels/discovered/(string: channelid)/torrents/(string: comma separated torrent infohashes + or * for all torrents in the channel) + Remove a single or multiple torrents with the given comma separated infohashes from a given channel. + restore option will revert the selected torrent from 'TODELETE' state to 'COMMITTED' state **Example request**: @@ -307,6 +306,9 @@ def render_DELETE(self, request): curl -X DELETE http://localhost:8085/channels/discovered/abcdefg/torrents/ 97d2d8f5d37e56cfaeaae151d55f05b077074779,971d55f05b077074779d2d8f5d37e56cfaeaae15 + curl -X DELETE http://localhost:8085/channels/discovered/abcdefg/torrents/ + 97d2d8f5d37e56cfaeaae151d55f05b077074779,971d55f05b077074779d2d8f5d37e56cfaeaae15?restore=1 + **Example response**: .. sourcecode:: javascript @@ -323,52 +325,36 @@ def render_DELETE(self, request): :statuscode 404: if the channel is not found """ - if self.is_chant_channel: - with db_session: - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - failed_torrents = [] - - if my_channel_id != self.cid: - request.setResponseCode(http.NOT_ALLOWED) - return json.dumps({"error": "you can only remove torrents from your own chant channel"}) - - my_channel = self.session.lm.mds.get_my_channel() - if not my_channel: - return ChannelsTorrentsEndpoint.return_404(request) - - for torrent_path in self.path.split(","): - infohash = torrent_path.decode('hex') - if not my_channel.delete_torrent_from_channel(infohash): - failed_torrents.append(torrent_path) + restore = 'restore' in request.args and request.args['restore'][0] == "1" - if failed_torrents: - return json.dumps({"removed": False, "failed_torrents": failed_torrents}) - return json.dumps({"removed": True}) - else: - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: + with db_session: + my_key = self.session.trustchain_keypair + my_channel_id = my_key.pub().key_to_bin() + failed_torrents = [] + + if my_channel_id != self.cid: + request.setResponseCode(http.NOT_ALLOWED) + return json.dumps({"error": "you can only remove torrents from your own chant channel"}) + + my_channel = self.session.lm.mds.get_my_channel() + if not my_channel: return ChannelsTorrentsEndpoint.return_404(request) - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, message=UNKNOWN_COMMUNITY_MSG) + if self.path == u'*': + if restore: + return json.dumps({"error": "trying to mass restore channel contents: not implemented"}) - torrent_db_columns = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', - 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.dispersy_id'] + my_channel.drop_channel_contents() + return json.dumps({"removed": True}) - failed_torrents = [] for torrent_path in self.path.split(","): - torrent_info = self.channel_db_handler.getTorrentFromChannelId(channel_info[0], - torrent_path.decode('hex'), - torrent_db_columns) - if torrent_info is None: + infohash = unhexlify(torrent_path) + if restore: + if not my_channel.cancel_torrent_deletion(infohash): + failed_torrents.append(torrent_path) + elif not my_channel.delete_torrent_from_channel(infohash): failed_torrents.append(torrent_path) - else: - # the 8th index is the dispersy id of the channel torrent - channel_community.remove_torrents([torrent_info[8]]) if failed_torrents: return json.dumps({"removed": False, "failed_torrents": failed_torrents}) - return json.dumps({"removed": True}) diff --git a/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py b/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py index 449a1910ffb..d11ec7c47ab 100644 --- a/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py +++ b/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py @@ -1,4 +1,6 @@ +from __future__ import absolute_import import os +from binascii import hexlify from pony.orm import db_session from twisted.web import http @@ -42,33 +44,20 @@ def render_GET(self, request): :statuscode 404: if your channel has not been created (yet). """ - if self.session.config.get_chant_channel_edit(): - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - with db_session: - my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - - if not my_channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - - my_channel = my_channel.to_dict() - return json.dumps({ - 'mychannel': { - 'identifier': str(my_channel["public_key"]).encode('hex'), - 'name': my_channel["title"], - 'description': my_channel["tags"], - 'chant': True - }}) - else: - my_channel_id = self.channel_db_handler.getMyChannelId() - if my_channel_id is None: + my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) + + if not my_channel: request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - my_channel = self.channel_db_handler.getChannel(my_channel_id) - - return json.dumps({'mychannel': {'identifier': my_channel[1].encode('hex'), 'name': my_channel[2], - 'description': my_channel[3]}}) + my_channel = my_channel.to_dict() + return json.dumps({ + 'mychannel': { + 'identifier': hexlify(str(my_channel["public_key"])), + 'name': my_channel["title"], + 'description': my_channel["tags"]}}) def render_POST(self, request): """ @@ -100,50 +89,28 @@ def render_POST(self, request): request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": 'channel name cannot be empty'}) - if self.session.config.get_chant_channel_edit(): - with db_session: - modified = False - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - - if not my_channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - - if get_parameter(parameters, 'name'): - my_channel.update_metadata(update_dict={ - "tags": unicode(get_parameter(parameters, 'description'), 'utf-8'), - "title": unicode(get_parameter(parameters, 'name'), 'utf-8') - }) - modified = True - - if get_parameter(parameters, 'commit_changes') and my_channel.staged_entries_list: - # Update torrent if we have uncommitted content in the channel - my_channel.commit_channel_torrent() - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - self.session.lm.updated_my_channel(torrent_path) - modified = True + with db_session: + modified = False + my_key = self.session.trustchain_keypair + my_channel_id = my_key.pub().key_to_bin() + my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - return json.dumps({'modified': modified}) - else: - my_channel_id = self.channel_db_handler.getMyChannelId() - if my_channel_id is None: + if not my_channel: request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - channel_community = self.get_community_for_channel_id(my_channel_id) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the your channel cannot be found") + if get_parameter(parameters, 'name'): + my_channel.update_metadata(update_dict={ + "tags": str(get_parameter(parameters, 'description')).encode('utf-8'), + "title": str(get_parameter(parameters, 'name')).encode('utf-8') + }) + modified = True - my_channel = self.channel_db_handler.getChannel(my_channel_id) - changes = {} - if my_channel[2] != get_parameter(parameters, 'name'): - changes['name'] = unicode(get_parameter(parameters, 'name'), 'utf-8') - if my_channel[3] != get_parameter(parameters, 'description'): - changes['description'] = unicode(get_parameter(parameters, 'description'), 'utf-8') + if get_parameter(parameters, 'commit_changes') and my_channel.staged_entries_list: + # Update torrent if we have uncommitted content in the channel + my_channel.commit_channel_torrent() + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) + modified = True - channel_community.modifyChannel(changes) - - return json.dumps({'modified': True}) + return json.dumps({'modified': modified}) diff --git a/Tribler/Core/Modules/restapi/downloads_endpoint.py b/Tribler/Core/Modules/restapi/downloads_endpoint.py index ba84876da93..962071c222b 100644 --- a/Tribler/Core/Modules/restapi/downloads_endpoint.py +++ b/Tribler/Core/Modules/restapi/downloads_endpoint.py @@ -2,20 +2,22 @@ import logging -from six import text_type, unichr # pylint: disable=redefined-builtin +from pony.orm import db_session +from six import text_type +from six import unichr # pylint: disable=redefined-builtin from six.moves.urllib.parse import unquote_plus from six.moves.urllib.request import url2pathname - from twisted.web import http, resource from twisted.web.server import NOT_DONE_YET +import Tribler.Core.Utilities.json_util as json from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.restapi.util import return_handled_exception from Tribler.Core.Utilities.utilities import unichar_string from Tribler.Core.exceptions import InvalidSignatureException from Tribler.Core.simpledefs import DOWNLOAD, UPLOAD, dlstatus_strings, DLMODE_VOD -import Tribler.Core.Utilities.json_util as json +from Tribler.pyipv8.ipv8.database import database_blob from Tribler.util import cast_to_unicode_utf8 @@ -223,15 +225,23 @@ def render_GET(self, request): num_seeds, num_peers = state.get_num_seeds_peers() num_connected_seeds, num_connected_peers = download.get_num_connected_seeds_peers() - def get_chant_name(download): - infohash = download.tdef.get_infohash() - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_infohash(infohash) - if channel: + @db_session + def get_chant_name(name, infohash): + channel = None + try: + channel = self.session.lm.mds.ChannelMetadata.get_channel_with_dirname(name) + except UnicodeEncodeError: + channel = self.session.lm.mds.ChannelMetadata.get_channel_with_infohash(infohash) + + if not channel: + return name + if channel.infohash == database_blob(infohash): return channel.title else: - return u"" + return u'OLD:' + channel.title - download_json = {"name": get_chant_name(download) if download.get_channel_download() else tdef.get_name_utf8(), + download_json = {"name": get_chant_name(tdef.get_name_utf8(), + tdef.get_infohash()) if download.get_channel_download() else tdef.get_name_utf8(), "progress": state.get_progress(), "infohash": tdef.get_infohash().encode('hex'), "speed_down": state.get_current_payload_speed(DOWNLOAD), @@ -336,7 +346,14 @@ def on_error(error): request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "Metadata has invalid signature"}) - download, _ = self.session.lm.update_channel(payload) + with db_session: + channel = self.session.lm.mds.process_payload(payload) + if channel and not channel.subscribed and channel.local_version < channel.version: + channel.subscribed = True + download, _ = self.session.lm.gigachannel_manager.download_channel(channel) + else: + return json.dumps({"error": "Already subscribed"}) + return json.dumps({"started": True, "infohash": str(download.get_def().get_infohash()).encode('hex')}) else: download_uri = u"file:%s" % url2pathname(uri[5:]).decode('utf-8') diff --git a/Tribler/Core/Modules/restapi/events_endpoint.py b/Tribler/Core/Modules/restapi/events_endpoint.py index 89988a7fe29..1959e0cc4db 100644 --- a/Tribler/Core/Modules/restapi/events_endpoint.py +++ b/Tribler/Core/Modules/restapi/events_endpoint.py @@ -76,7 +76,6 @@ def __init__(self, session): self.session.add_observer(self.on_tribler_started, NTFY_TRIBLER, [NTFY_STARTED]) self.session.add_observer(self.on_channel_discovered, NTFY_CHANNEL, [NTFY_DISCOVERED]) self.session.add_observer(self.on_torrent_discovered, NTFY_TORRENT, [NTFY_DISCOVERED]) - self.session.add_observer(self.on_torrent_removed_from_channel, NTFY_TORRENT, [NTFY_DELETE]) self.session.add_observer(self.on_torrent_finished, NTFY_TORRENT, [NTFY_FINISHED]) self.session.add_observer(self.on_torrent_error, NTFY_TORRENT, [NTFY_ERROR]) self.session.add_observer(self.on_market_ask, NTFY_MARKET_ON_ASK, [NTFY_UPDATE]) @@ -170,9 +169,6 @@ def on_channel_discovered(self, subject, changetype, objectID, *args): def on_torrent_discovered(self, subject, changetype, objectID, *args): self.write_data({"type": "torrent_discovered", "event": args[0]}) - def on_torrent_removed_from_channel(self, subject, changetype, objectID, *args): - self.write_data({"type": "torrent_removed_from_channel", "event": args[0]}) - def on_torrent_finished(self, subject, changetype, objectID, *args): self.write_data({"type": "torrent_finished", "event": {"infohash": objectID.encode('hex'), "name": args[0]}}) diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 8a266c29a69..597da10e908 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -1,14 +1,31 @@ +from __future__ import absolute_import import logging +from binascii import unhexlify -from pony.orm import db_session +from pony.orm import db_session, desc, select from twisted.web import http, resource -from Tribler.Core.Modules.restapi.util import convert_channel_metadata_to_tuple, convert_torrent_metadata_to_tuple -from Tribler.Core.Utilities.search_utils import split_into_keywords -from Tribler.Core.exceptions import OperationNotEnabledByConfigurationException -from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_TORRENTS, SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, \ - SIGNAL_CHANNEL import Tribler.Core.Utilities.json_util as json +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, REGULAR_TORRENT +from Tribler.Core.Modules.restapi.util import convert_torrent_metadata_to_tuple, convert_db_torrent_to_json, \ + channel_to_torrent_adapter +from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_TORRENTS + +metadata_type_conversion_dict = {u'channel': CHANNEL_TORRENT, + u'torrent': REGULAR_TORRENT} + + +def shift_and_clamp(x, s): + return x - s if x > s else 0 + + +json2pony_columns = {u'category': "tags", + u'id': "rowid", + u'name': "title", + u'size': "size", + u'infohash': "infohash", + u'date': "torrent_date", + u'commit_status': 'status'} class SearchEndpoint(resource.Resource): @@ -31,73 +48,198 @@ def render_GET(self, request): """ .. http:get:: /search?q=(string:query) - A GET request to this endpoint will create a search. Results are returned over the events endpoint, one by one. - First, the results available in the local database will be pushed. After that, incoming Dispersy results are - pushed. The query to this endpoint is passed using the url, i.e. /search?q=pioneer. + A GET request to this endpoint will create a search. + + first and last options limit the range of the query. + xxx_filter option disables xxx filter + channel option limits search to a certain channel + sort_by option sorts results in forward or backward, based on column name (e.g. "id" vs "-id") + txt option uses FTS search on the chosen word* terms + type option limits query to certain metadata types (e.g. "torrent" or "channel") + subscribed option limits query to channels you are subscribed for **Example request**: .. sourcecode:: none - curl -X GET http://localhost:8085/search?q=tribler + curl -X GET 'http://localhost:8085/search?txt=ubuntu&first=0&last=30&type=torrent&sort_by=size' **Example response**: .. sourcecode:: javascript { - "type": "search_result_channel", - "query": "test", - "result": { - "id": 3, - "dispersy_cid": "da69aaad39ccf468aba2ab9177d5f8d8160135e6", - "name": "My fancy channel", - "description": "A description of this fancy channel", - "subscribed": True, - "votes": 23, - "torrents": 3, - "spam": 5, - "modified": 14598395, - "can_edit": False - } + "torrents":[ + { + "commit_status":1, + "num_leechers":0, + "date":"1539867830.0", + "relevance_score":0, + "id":21, + "size":923795456, + "category":"unknown", + "public_key":"4c69624e...", + "name":"ubuntu-18.10-live-server-amd64.iso", + "last_tracker_check":0, + "infohash":"8c4adbf9ebe66f1d804fb6a4fb9b74966c3ab609", + "num_seeders":0, + "type":"torrent" + }, + ... + ], + "chant_dirty":false } + """ - if 'q' not in request.args: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "query parameter missing"}) - # Notify the events endpoint that we are starting a new search query - self.events_endpoint.start_new_query() - - # We first search the local database for torrents and channels - query = unicode(request.args['q'][0], 'utf-8') - keywords = split_into_keywords(query) - - results_local_channels = self.channel_db_handler.search_in_local_channels_db(query) - with db_session: - results_local_channels.extend(map(convert_channel_metadata_to_tuple, - self.session.lm.mds.ChannelMetadata.search_keyword(query))) - - results_dict = {"keywords": keywords, "result_list": results_local_channels} - self.session.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - - torrent_db_columns = ['T.torrent_id', 'infohash', 'T.name', 'length', 'category', - 'num_seeders', 'num_leechers', 'last_tracker_check'] - results_local_torrents = self.torrent_db_handler.search_in_local_torrents_db(query, keys=torrent_db_columns) - with db_session: - results_local_torrents.extend(map(convert_torrent_metadata_to_tuple, - self.session.lm.mds.TorrentMetadata.search_keyword(query))) - results_dict = {"keywords": keywords, "result_list": results_local_torrents} - self.session.notifier.notify(SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - - # Create remote searches - try: - self.session.search_remote_torrents(keywords) - self.session.search_remote_channels(keywords) - except OperationNotEnabledByConfigurationException as exc: - self._logger.error(exc) - - return json.dumps({"queried": True}) + + + first = 0 + last = None + item_type = None + channel_id = None + txt_search_query = None + sort_forward = True + sort_column = u'id' + sort_by = None + chant_dirty = False + subscribed = None + channel = None + + xxx_filter = self.session.config.get_family_filter_enabled() + if 'xxx_filter' in request.args and request.args['xxx_filter'] > 0 \ + and request.args['xxx_filter'][0] == "1": + xxx_filter = False + + if 'first' in request.args and request.args['first'] > 0: + first = int(request.args['first'][0]) + + if 'last' in request.args and request.args['last'] > 0: + last = int(request.args['last'][0]) + + if 'type' in request.args and request.args['type'] > 0: + item_type = str(request.args['type'][0]) + + if 'channel' in request.args and request.args['channel'] > 0: + channel_id = unhexlify(request.args['channel'][0]) + + if 'sort_by' in request.args and request.args['sort_by'] > 0: + sort_by = request.args['sort_by'][0] + if sort_by.startswith(u'-'): + sort_forward = False + sort_column = sort_by[1:] + else: + sort_forward = True + sort_column = sort_by + + if 'txt' in request.args and request.args['txt'] > 0: + txt_search_query = request.args['txt'][0] + + if 'subscribed' in request.args and request.args['subscribed'] > 0: + subscribed = int(request.args['subscribed'][0]) + + results = [] + is_dispersy_channel = (len(channel_id) != 74) if channel_id else False + + # ACHTUNG! In its current form, the endpoint is carefully _designed_ to mix legacy and Pony results + # together correctly in regards to pagination! Befor sending results for a page, it considers the whole + # query size for _both_ legacy and Pony DBs, and then places the results correctly (Pony first, legacy last). + + # Legacy query for channel contents + if is_dispersy_channel: + channels_list = self.channel_db_handler.getChannelsByCID([channel_id]) + channel_info = channels_list[0] if channels_list else None + if channel_info is None: + return json.dumps({"error": "Channel with given Dispersy ID is not found"}) + + torrent_db_columns = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', + 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.inserted'] + results = self.channel_db_handler.getTorrentsFromChannelId(channel_info[0], True, torrent_db_columns, + first=first, last=last) + else: + with db_session: + # Object class to query + base_type = self.session.lm.mds.TorrentMetadata + if item_type == u'channel' or subscribed: + base_type = self.session.lm.mds.ChannelMetadata + + # Achtung! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere !!! + pony_query = select(g for g in base_type) + + # Add FTS search terms + if txt_search_query: + pony_query = base_type.search_keyword(txt_search_query + "*", lim=1000) + + # Filter by channel id + if channel_id: + channel = self.session.lm.mds.ChannelMetadata.get(public_key=channel_id) + chant_dirty = channel.dirty + if not channel: + return json.dumps({"error": "Channel with given public key is not found"}) + pony_query = pony_query.where(public_key=channel.public_key, metadata_type=REGULAR_TORRENT) + + # Filter by metadata type + if item_type: + if item_type not in metadata_type_conversion_dict: + return json.dumps({"error": "Unknown metadata type queried: " + item_type}) + pony_query = pony_query.where(metadata_type=metadata_type_conversion_dict[item_type]) + + # Filter subscribed/non-subscribed + if subscribed is not None: + pony_query = pony_query.where(subscribed=bool(subscribed)) + + pony_query_size = pony_query.count() + # Sort the query + if sort_by: + sort_expression = "g." + json2pony_columns[sort_column] + sort_expression = sort_expression if sort_forward else desc(sort_expression) + pony_query = pony_query.sort_by(sort_expression) + + pony_query_results = [convert_torrent_metadata_to_tuple(md) for md in pony_query[first:last]] + results.extend(pony_query_results) + + # Legacy query for subscribed channels + skip_dispersy = not txt_search_query or (channel and not is_dispersy_channel) + if subscribed: + skip_dispersy = True + subscribed_channels_db = self.channel_db_handler.getMySubscribedChannels(include_dispersy=True) + results.extend([channel_to_torrent_adapter(c) for c in subscribed_channels_db]) + + previous_query_size = pony_query_size + if not skip_dispersy: + # Legacy query for channels + if item_type not in metadata_type_conversion_dict or item_type == u'channel': + first2 = shift_and_clamp(first, previous_query_size) + last2 = shift_and_clamp(last, previous_query_size) + dispersy_channels = [] + count = 0 + if txt_search_query: + dispersy_channels = self.channel_db_handler.search_in_local_channels_db(txt_search_query, + first=first2, + last=last2) + count = self.channel_db_handler.search_in_local_channels_db(txt_search_query, count=True)[0][0] + elif not channel_id: + dispersy_channels = self.channel_db_handler.getAllChannels(first=first, last=last) + count = self.channel_db_handler.getAllChannelsCount()[0][0] + results.extend([channel_to_torrent_adapter(c) for c in dispersy_channels]) + previous_query_size += count + + # Legacy query for torrents + if (item_type not in metadata_type_conversion_dict or item_type == u'torrent') and not channel_id: + first3 = shift_and_clamp(first, previous_query_size) + last3 = shift_and_clamp(last, previous_query_size) + torrent_db_columns = ['T.torrent_id', 'infohash', 'T.name', 'length', 'category', + 'num_seeders', 'num_leechers', 'last_tracker_check'] + dispersy_torrents = self.torrent_db_handler.search_in_local_torrents_db(txt_search_query, + keys=torrent_db_columns, + first=first3, + last=last3, + family_filter=xxx_filter) + results.extend(dispersy_torrents) + + results_json = [convert_db_torrent_to_json(t) for t in results] + + return json.dumps({"torrents": results_json, "chant_dirty": chant_dirty}) class SearchCompletionsEndpoint(resource.Resource): diff --git a/Tribler/Core/Modules/restapi/util.py b/Tribler/Core/Modules/restapi/util.py index 35b19dde460..6469674b0af 100644 --- a/Tribler/Core/Modules/restapi/util.py +++ b/Tribler/Core/Modules/restapi/util.py @@ -1,7 +1,10 @@ +from __future__ import absolute_import + +from binascii import hexlify + """ This file contains some utility methods that are used by the API. """ -from __future__ import absolute_import from six import string_types from six.moves import xrange @@ -9,9 +12,20 @@ from twisted.web import http import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.MetadataStore.serialization import time2float +from Tribler.Core.Modules.MetadataStore.serialization import time2float, CHANNEL_TORRENT, float2time from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE +HEALTH_CHECKING = u'Checking..' +HEALTH_DEAD = u'No peers' +HEALTH_ERROR = u'Error' +HEALTH_MOOT = u'Peers found' +HEALTH_GOOD = u'Seeds found' +HEALTH_UNCHECKED = u'Unknown' + +CATEGORY_OLD_CHANNEL = u'Old channel' +CATEGORY_CHANNEL = u'Channel' +CATEGORY_UNKNOWN = u'Unknown' + def return_handled_exception(request, exception): """ @@ -41,11 +55,11 @@ def convert_channel_metadata_to_tuple(metadata): spam = 0 relevance = 0.9 unix_timestamp = time2float(metadata.timestamp) - return (metadata.rowid, str(metadata.public_key), metadata.title, metadata.tags, int(metadata.size), votes, spam, - my_vote, unix_timestamp, relevance) + return metadata.rowid, str(metadata.public_key), metadata.title, metadata.tags, int(metadata.size), votes, spam, \ + my_vote, unix_timestamp, relevance, metadata.status, metadata.torrent_date, metadata.metadata_type -def convert_torrent_metadata_to_tuple(metadata, commit_status=None): +def convert_torrent_metadata_to_tuple(metadata): """ Convert some given torrent metadata to a tuple, similar to returned torrents from the database. :param metadata: The metadata to convert. @@ -54,12 +68,16 @@ def convert_torrent_metadata_to_tuple(metadata, commit_status=None): seeders = 0 leechers = 0 last_tracker_check = 0 - category = 'unknown' + original_category = metadata.tags.split(' ', 1)[0] if metadata.tags else CATEGORY_UNKNOWN + category = CATEGORY_CHANNEL if metadata._discriminator_ == CHANNEL_TORRENT else original_category infohash = str(metadata.infohash) relevance = 0.9 - + subscribed = '' + if metadata._discriminator_ == CHANNEL_TORRENT: + subscribed = 1 if metadata.subscribed else 0 return (metadata.rowid, infohash, metadata.title, int(metadata.size), category, seeders, leechers, - last_tracker_check, None, relevance, commit_status) + last_tracker_check, None, relevance, metadata.status, metadata.torrent_date, metadata.metadata_type, + hexlify(metadata.public_key), subscribed) def convert_search_torrent_to_json(torrent): @@ -76,7 +94,7 @@ def convert_db_channel_to_json(channel, include_rel_score=False): """ This method converts a channel in the database to a JSON dictionary. """ - res_json = {"id": channel[0], "dispersy_cid": channel[1].encode('hex'), "name": channel[2], + res_json = {"id": channel[0], "dispersy_cid": hexlify(channel[1]), "name": channel[2], "description": channel[3], "votes": channel[5], "torrents": channel[4], "spam": channel[6], "modified": channel[8], "subscribed": (channel[7] == VOTE_SUBSCRIBE)} @@ -86,12 +104,25 @@ def convert_db_channel_to_json(channel, include_rel_score=False): return res_json +def channel_to_torrent_adapter(channel): + return (channel[0], '', channel[2], channel[4], + CATEGORY_OLD_CHANNEL, 0, 0, + 0, + 0, + 0, + 0, + float2time(0), + CHANNEL_TORRENT, + hexlify(channel[1]), + int(channel[7] == VOTE_SUBSCRIBE)) + + def convert_chant_channel_to_json(channel): """ This method converts a chant channel entry to a JSON dictionary. """ # TODO: this stuff is mostly placeholder, especially 'modified' field. Should be changed when Dispersy is out. - res_json = {"id": 0, "dispersy_cid": str(channel.public_key).encode('hex'), "name": channel.title, + res_json = {"id": 0, "dispersy_cid": hexlify(channel.public_key), "name": channel.title, "description": channel.tags, "votes": channel.votes, "torrents": channel.size, "spam": 0, "modified": channel.version, "subscribed": channel.subscribed} @@ -106,16 +137,20 @@ def convert_db_torrent_to_json(torrent, include_rel_score=False): if torrent_name is None or len(torrent_name.strip()) == 0: torrent_name = "Unnamed torrent" - res_json = {"id": torrent[0], "infohash": torrent[1].encode('hex'), "name": torrent_name, "size": torrent[3], - "category": torrent[4], "num_seeders": torrent[5] or 0, "num_leechers": torrent[6] or 0, - "last_tracker_check": torrent[7] or 0} - - if len(torrent) >= 11: - res_json["commit_status"] = torrent[10] - - if include_rel_score: - res_json["relevance_score"] = torrent[9] - + res_json = {"id": torrent[0], "infohash": hexlify(torrent[1]), "name": torrent_name, "size": torrent[3] or 0, + "category": torrent[4] if torrent[4] else "unknown", "num_seeders": torrent[5] or 0, + "num_leechers": torrent[6] or 0, + "last_tracker_check": torrent[7] or 0, + "commit_status": torrent[10] if len(torrent) >= 11 else 0, + "date": str(time2float(torrent[11])) if len(torrent) >= 12 else 0, + "type": str('channel' if len(torrent) >= 13 and torrent[12] == CHANNEL_TORRENT else 'torrent'), + "public_key": str(torrent[13]) if len(torrent) >= 14 else '', + "relevance_score": torrent[9] if include_rel_score else 0, + "subscribed": str(torrent[14]) if len(torrent) >= 15 else '', + "health": HEALTH_GOOD if torrent[5] else HEALTH_UNCHECKED, + "dispersy_cid": str(torrent[13]) if len(torrent) >= 14 else '', + "votes": 0 + } return res_json @@ -127,7 +162,7 @@ def convert_remote_torrent_to_json(torrent): if torrent_name is None or len(torrent_name.strip()) == 0: torrent_name = "Unnamed torrent" - return {'id': torrent['torrent_id'], "infohash": torrent['infohash'].encode('hex'), "name": torrent_name, + return {'id': torrent['torrent_id'], "infohash": hexlify(torrent['infohash']), "name": torrent_name, 'size': torrent['length'], 'category': torrent['category'], 'num_seeders': torrent['num_seeders'], 'num_leechers': torrent['num_leechers'], 'last_tracker_check': 0} diff --git a/Tribler/Test/Community/gigachannel/test_community.py b/Tribler/Test/Community/gigachannel/test_community.py index c9998226688..1b4144d90c2 100644 --- a/Tribler/Test/Community/gigachannel/test_community.py +++ b/Tribler/Test/Community/gigachannel/test_community.py @@ -1,229 +1,80 @@ +import os + +from pony.orm import db_session from twisted.internet.defer import inlineCallbacks -from Tribler.community.gigachannel.community import ChannelDownloadCache, GigaChannelCommunity +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Utilities.random_utils import random_infohash +from Tribler.community.gigachannel.community import GigaChannelCommunity +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto from Tribler.pyipv8.ipv8.peer import Peer from Tribler.pyipv8.ipv8.test.base import TestBase -from Tribler.Test.mocking.channel import MockChannel -from Tribler.Test.mocking.download import MockDownload -from Tribler.Test.mocking.session import MockSession class TestGigaChannelUnits(TestBase): - """ Unit tests for the GigaChannel community which do not need a real Session. """ def setUp(self): super(TestGigaChannelUnits, self).setUp() - self.session = MockSession() - - self.initialize(GigaChannelCommunity, 1) + self.count = 0 + self.initialize(GigaChannelCommunity, 2) def create_node(self, *args, **kwargs): - kwargs['tribler_session'] = self.session - return super(TestGigaChannelUnits, self).create_node(*args, **kwargs) - - def _setup_fetch_next(self): - """ - Setup phase for fetch_next() tests. - - Provides: - - Database entry for a mocked Channel. - - download_channel() functionality for the mocked channel. - - Pending overlay.download_queue for the mocked channel. - """ - channel, download = self._setup_download_completed() - self.session.lm.set_download_channel(download) - self.nodes[0].overlay.download_queue = [channel.infohash] - - return channel, download - - def _setup_download_completed(self): - """ - Setup phase for the download_completed() tests. - - Provides: - - Database entry for a mocked Channel. - - Mocked (empty) download_channel() functionality. - """ - channel = MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0) - self.session.lm.mds.ChannelMetadata.add(channel) - download = MockDownload() - download.tdef.set_infohash(channel.infohash) - - return channel, download - - def test_select_random_none(self): - """ - No entries in the database should yield no results. - """ - channel_list = [] - self.session.lm.mds.ChannelMetadata.set_random_channels(channel_list) - - entries = self.nodes[0].overlay.get_random_entries() - - self.assertEqual(0, len(entries)) - - def test_select_random_one(self): - """ - One entry in the database should yield one result. - """ - channel_list = [MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0)] - self.session.lm.mds.ChannelMetadata.set_random_channels(channel_list) - - entries = self.nodes[0].overlay.get_random_entries() - - self.assertEqual(1, len(entries)) - self.assertEqual(entries[0].infohash, channel_list[0].infohash) - self.assertEqual(entries[0].public_key, channel_list[0].public_key[10:]) - self.assertEqual(entries[0].title, channel_list[0].title) - self.assertEqual(entries[0].version, channel_list[0].version) - - def test_select_random_many(self): - """ - Six entries in the database should yield six results. - """ - channel_list = [MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0)] * 6 - self.session.lm.mds.ChannelMetadata.set_random_channels(channel_list) - - entries = self.nodes[0].overlay.get_random_entries() - - self.assertEqual(6, len(entries)) - for entry in entries: - self.assertEqual(entry.infohash, channel_list[0].infohash) - self.assertEqual(entry.public_key, channel_list[0].public_key[10:]) - self.assertEqual(entry.title, channel_list[0].title) - self.assertEqual(entry.version, channel_list[0].version) - - def test_select_random_too_many(self): - """ - Ten entries in the database should be capped at seven results. - """ - channel_list = [MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0)] * 10 - self.session.lm.mds.ChannelMetadata.set_random_channels(channel_list) - - entries = self.nodes[0].overlay.get_random_entries() - - self.assertEqual(7, len(entries)) - for entry in entries: - self.assertEqual(entry.infohash, channel_list[0].infohash) - self.assertEqual(entry.public_key, channel_list[0].public_key[10:]) - self.assertEqual(entry.title, channel_list[0].title) - self.assertEqual(entry.version, channel_list[0].version) - - def test_update_with_download(self): - """ - Test if an update with a download extracts the seeder count as votes. - """ - channel, download = self._setup_download_completed() - - self.assertEqual(0, channel.votes) - - self.nodes[0].overlay.update_from_download(download) - - self.assertEqual(42, channel.votes) - - def test_download_completed_no_token(self): - """ - Test if the download completed callback extracts the seeder count as votes. - """ - channel, download = self._setup_download_completed() - - self.assertEqual(0, channel.votes) - - self.nodes[0].overlay.download_completed(download) - - self.assertEqual(42, channel.votes) - - def test_download_completed_with_token(self): - """ - Test if the download completed callback releases the download token. - """ - channel, download = self._setup_download_completed() - - token = ChannelDownloadCache(self.nodes[0].overlay.request_cache) - self.nodes[0].overlay.request_cache.add(token) - - self.nodes[0].overlay.download_completed(download) - - self.assertFalse(self.nodes[0].overlay.request_cache.has(token.prefix, token.number)) - - def test_fetch_next_no_token(self): - """ - Test if nothing happens when we fetch the next download without holding the download token. - """ - channel, download = self._setup_fetch_next() - - token = ChannelDownloadCache(self.nodes[0].overlay.request_cache) - self.nodes[0].overlay.request_cache.add(token) - - self.nodes[0].overlay.fetch_next() - - self.nodes[0].overlay.request_cache.pop(token.prefix, token.number) - - self.assertEqual(1, len(self.nodes[0].overlay.download_queue)) - - def test_fetch_next_already_known(self): - """ - Test if we throw out a download when we fetch a download we already know. - """ - channel, download = self._setup_fetch_next() - self.session.add_known_infohash(channel.infohash) - - self.nodes[0].overlay.fetch_next() - - self.assertEqual(0, len(self.nodes[0].overlay.download_queue)) - - @inlineCallbacks - def test_fetch_next(self): - """ - Test if we download a channel if we have nothing else to do. - """ - channel, download = self._setup_fetch_next() - - self.nodes[0].overlay.fetch_next() - - self.assertTrue(self.session.lm.downloading) - - self.assertEqual(0, channel.votes) - - self.session.lm.finish_download_channel() - - yield self.session.lm.downloaded_channel_deferred - - self.assertFalse(self.session.lm.downloading) - self.assertEqual(42, channel.votes) + metadata_store = MetadataStore(os.path.join(self.temporary_directory(), "%d.db" % self.count), + self.temporary_directory(), default_eccrypto.generate_key(u"curve25519")) + kwargs['metadata_store'] = metadata_store + node = super(TestGigaChannelUnits, self).create_node(*args, **kwargs) + self.count += 1 + return node + + def add_random_torrent(self, metadata_cls): + torrent_metadata = metadata_cls.from_dict({ + "infohash": random_infohash(), + "title": "test", + "tags": "", + "size": 1234, + "status": NEW + }) + torrent_metadata.sign() @inlineCallbacks - def test_send_random_to_known_new(self): + def test_send_random_one_channel(self): """ - Test if we do not add new downloads to the queue if we get sent a new channel. + Test whether sending a single channel with a single torrent to another peer works correctly """ - channel = MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0) - self.session.lm.mds.ChannelMetadata.set_random_channels([channel]) + with db_session: + channel = self.nodes[0].overlay.metadata_store.ChannelMetadata.create_channel("test", "bla") + self.add_random_torrent(self.nodes[0].overlay.metadata_store.TorrentMetadata) + channel.commit_channel_torrent() - self.nodes[0].overlay.send_random_to(Peer(self.nodes[0].my_peer.public_key, self.nodes[0].endpoint.wan_address)) + self.nodes[0].overlay.send_random_to(Peer(self.nodes[1].my_peer.public_key, self.nodes[1].endpoint.wan_address)) yield self.deliver_messages() - self.assertEqual(1, len(self.nodes[0].overlay.download_queue)) - self.assertIn(channel.infohash, self.nodes[0].overlay.download_queue) + with db_session: + self.assertEqual(len(self.nodes[1].overlay.metadata_store.ChannelMetadata.select()), 1) + channel = self.nodes[1].overlay.metadata_store.ChannelMetadata.select()[:][0] + self.assertEqual(channel.contents_len, 1) @inlineCallbacks - def test_send_random_to_known_update(self): + def test_send_random_multiple_torrents(self): """ - Test if we do not add new downloads to the queue if we get sent a new channel. + Test whether sending a single channel with a multiple torrents to another peer works correctly """ - old_channel = MockChannel('\x00' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 1, 0) - self.session.lm.mds.ChannelMetadata.add(old_channel) - new_channel = MockChannel('\x01' * 20, 'LibNaCLPK:' + '\x00' * 64, 'test', 2, 0) - self.session.lm.mds.ChannelMetadata.set_random_channels([new_channel]) + with db_session: + channel = self.nodes[0].overlay.metadata_store.ChannelMetadata.create_channel("test", "bla") + for _ in xrange(20): + self.add_random_torrent(self.nodes[0].overlay.metadata_store.TorrentMetadata) + channel.commit_channel_torrent() - self.nodes[0].overlay.send_random_to(Peer(self.nodes[0].my_peer.public_key, self.nodes[0].endpoint.wan_address)) + self.nodes[0].overlay.send_random_to(Peer(self.nodes[1].my_peer.public_key, self.nodes[1].endpoint.wan_address)) yield self.deliver_messages() - self.assertEqual(1, len(self.nodes[0].overlay.download_queue)) - self.assertIn(old_channel.infohash, self.nodes[0].overlay.download_queue) - self.assertEqual(old_channel.infohash, new_channel.infohash) + with db_session: + self.assertEqual(len(self.nodes[1].overlay.metadata_store.ChannelMetadata.select()), 1) + channel = self.nodes[1].overlay.metadata_store.ChannelMetadata.select()[:][0] + self.assertLess(channel.contents_len, 20) diff --git a/Tribler/Test/Community/gigachannel/test_community_fullsession.py b/Tribler/Test/Community/gigachannel/test_community_fullsession.py deleted file mode 100644 index 43c50c16a76..00000000000 --- a/Tribler/Test/Community/gigachannel/test_community_fullsession.py +++ /dev/null @@ -1,130 +0,0 @@ -from __future__ import absolute_import -import os - -from pony.orm import db_session -from six.moves import xrange -from twisted.internet import reactor -from twisted.internet.defer import inlineCallbacks -from twisted.internet.task import deferLater - -from Tribler.community.gigachannel.community import GigaChannelCommunity -from Tribler.Core.Session import Session -from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto -from Tribler.pyipv8.ipv8.peer import Peer -from Tribler.Test.test_as_server import TestAsServer - - -class TestGigaChannelCommunity(TestAsServer): - - @inlineCallbacks - def setUp(self): - yield TestAsServer.setUp(self) - - self.config2 = self.localize_config(self.config, 1) - self.session2 = Session(self.config2) - self.session2.upgrader_enabled = False - yield self.session2.start() - - self.sessions = [self.session, self.session2] - - self.test_class = GigaChannelCommunity - self.test_class.master_peer = Peer(default_eccrypto.generate_key(u"curve25519")) - - def localize_config(self, config, nr=0): - out = config.copy() - out.set_state_dir(self.getStateDir(nr)) - out.set_default_destination_dir(self.getDestDir(nr)) - out.set_permid_keypair_filename(os.path.join(self.getStateDir(nr), "keypair_" + str(nr))) - out.set_trustchain_keypair_filename(os.path.join(self.getStateDir(nr), "tc_keypair_" + str(nr))) - return out - - def setUpPreSession(self): - TestAsServer.setUpPreSession(self) - self.config.set_dispersy_enabled(False) - self.config.set_ipv8_enabled(True) - self.config.set_libtorrent_enabled(True) - self.config.set_trustchain_enabled(False) - self.config.set_resource_monitor_enabled(False) - self.config.set_tunnel_community_socks5_listen_ports(self.get_socks5_ports()) - self.config.set_chant_enabled(True) - self.config = self.localize_config(self.config) - - @inlineCallbacks - def tearDown(self): - yield self.session2.shutdown() - yield TestAsServer.tearDown(self) - - def _create_channel(self): - self.session.lm.mds.ChannelMetadata.create_channel('test' + ''.join(str(i) for i in range(100)), 'test') - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - with db_session: - my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - for ind in xrange(20): - random_infohash = '\x00' * 20 - self.session.lm.mds.TorrentMetadata(title='test ind %d' % ind, tags='test', - size=1234, infohash=random_infohash) - my_channel.commit_channel_torrent() - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - self.session.lm.updated_my_channel(torrent_path) - return my_channel_id - - def introduce_nodes(self): - self.session.lm.gigachannel_community.walk_to(self.session2.lm.gigachannel_community.my_estimated_lan) - return self.deliver_messages() - - @inlineCallbacks - def test_fetch_channel(self): - """ - Test if a fetch_next() call is answered with a channel. - """ - # Peer 1 creates a channel and introduces itself to peer 2 - channel_id = self._create_channel() - yield self.introduce_nodes() - - # Peer 1 sends its channel to peer 2 - peer2 = self.session2.lm.gigachannel_community.my_peer - peer2.address = self.session2.lm.gigachannel_community.my_estimated_lan - self.session.lm.gigachannel_community.send_random_to(peer2) - yield self.deliver_messages() - - # Peer 2 acts upon the known channels - self.session2.lm.gigachannel_community.fetch_next() - yield self.deliver_messages() - - with db_session: - channel_list1 = list(self.session.lm.mds.ChannelMetadata.select()) - channel_list2 = list(self.session2.lm.mds.ChannelMetadata.select()) - - self.assertEqual(1, len(channel_list1)) - self.assertEqual(1, len(channel_list2)) - self.assertEqual(channel_id, str(channel_list1[0].public_key)) - self.assertEqual(channel_id, str(channel_list2[0].public_key)) - self.assertTrue(self.session.has_download(str(channel_list1[0].infohash))) - self.assertTrue(self.session2.has_download(str(channel_list1[0].infohash))) - - @inlineCallbacks - def deliver_messages(self, timeout=.1): - """ - Allow peers to communicate. - The strategy is as follows: - 1. Measure the amount of working threads in the threadpool - 2. After 10 milliseconds, check if we are down to 0 twice in a row - 3. If not, go back to handling calls (step 2) or return, if the timeout has been reached - :param timeout: the maximum time to wait for messages to be delivered - """ - rtime = 0 - probable_exit = False - while rtime < timeout: - yield self.sleep(.01) - rtime += .01 - if len(reactor.getThreadPool().working) == 0: - if probable_exit: - break - probable_exit = True - else: - probable_exit = False - - @inlineCallbacks - def sleep(self, time=.05): - yield deferLater(reactor, time, lambda: None) diff --git a/Tribler/Test/Community/gigachannel/test_sync_strategy.py b/Tribler/Test/Community/gigachannel/test_sync_strategy.py index b3257005d41..0538fc6daaf 100644 --- a/Tribler/Test/Community/gigachannel/test_sync_strategy.py +++ b/Tribler/Test/Community/gigachannel/test_sync_strategy.py @@ -20,6 +20,7 @@ def fetch_next(self): def get_peers(self): return self.get_peers_return + class TestSyncChannels(TestBase): def setUp(self): @@ -29,12 +30,11 @@ def setUp(self): def test_strategy_no_peers(self): """ - If we have no peers, we should still inspect our download queue. + If we have no peers, no random entries should have been sent. """ self.strategy.take_step() self.assertListEqual([], self.community.send_random_to_called) - self.assertTrue(self.community.fetch_next_called) def test_strategy_one_peer(self): """ @@ -45,7 +45,6 @@ def test_strategy_one_peer(self): self.assertEqual(1, len(self.community.send_random_to_called)) self.assertEqual(self.community.get_peers_return[0], self.community.send_random_to_called[0]) - self.assertTrue(self.community.fetch_next_called) def test_strategy_multi_peer(self): """ @@ -59,4 +58,3 @@ def test_strategy_multi_peer(self): self.assertEqual(1, len(self.community.send_random_to_called)) self.assertIn(self.community.send_random_to_called[0], self.community.get_peers_return) - self.assertTrue(self.community.fetch_next_called) diff --git a/Tribler/Test/Core/Config/test_tribler_config.py b/Tribler/Test/Core/Config/test_tribler_config.py index b12473a200f..d5a738d2e2f 100644 --- a/Tribler/Test/Core/Config/test_tribler_config.py +++ b/Tribler/Test/Core/Config/test_tribler_config.py @@ -282,8 +282,6 @@ def test_get_set_chant_methods(self): """ self.tribler_config.set_chant_enabled(False) self.assertFalse(self.tribler_config.get_chant_enabled()) - self.tribler_config.set_chant_channel_edit(True) - self.assertTrue(self.tribler_config.get_chant_channel_edit()) self.tribler_config.set_chant_channels_dir('test') self.assertEqual(self.tribler_config.get_chant_channels_dir(), os.path.join(self.tribler_config.get_state_dir(), 'test')) diff --git a/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py b/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py index 531619d5d81..99ef856bac8 100644 --- a/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py +++ b/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py @@ -57,24 +57,6 @@ def test_existing_channel_lookup(self): self.assertEqual(source.community, community, 'ChannelSource failed to find existing ChannelCommunity') source.stop() - @inlineCallbacks - def test_torrent_from_db(self): - # Torrent is a tuple: (channel_id, dispersy_id, peer_id, infohash, timestamp, name, files, trackers) - torrent = (0, self.cid, 42, '\00' * 20, 0, u'torrent', [], []) - channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - channel_db_handler.on_torrents_from_dispersy([torrent]) - - torrent_inserteds = [] - torrent_insert_callback = lambda source, infohash, name: torrent_inserteds.append((source, infohash, name)) - source = ChannelSource(self.session, self.cid, torrent_insert_callback) - source.start() - - yield deferLater(reactor, 1, lambda: None) - self.assertIn((self.cid, hexlify(torrent[3]), torrent[5]), torrent_inserteds, - 'ChannelSource failed to insert torrent') - - source.stop() - def test_torrent_discovered(self): torrent_inserteds = [] torrent_insert_callback = lambda source, infohash, name: torrent_inserteds.append((source, infohash, name)) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py index ce647315d81..2b4907bc184 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py @@ -45,19 +45,14 @@ def test_channel_update_and_download(self): payload = ChannelMetadataPayload.from_file(CHANNEL_METADATA_UPDATED) # Download the channel in our session - download, finished_deferred = self.session.lm.update_channel(payload) + with db_session: + channel = self.session.lm.mds.process_payload(payload) + download, finished_deferred = self.session.lm.gigachannel_manager.download_channel(channel) download.add_peer(("127.0.0.1", self.seeder_session.config.get_libtorrent_port())) yield finished_deferred with db_session: # There should be 4 torrents + 1 channel torrent - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(payload.public_key) + channel2 = self.session.lm.mds.ChannelMetadata.get_channel_with_id(payload.public_key) self.assertEqual(5, len(list(self.session.lm.mds.TorrentMetadata.select()))) - self.assertEqual(4, channel.local_version) - - def test_wrong_signature_exception_on_channel_update(self): - # Test wrong signature exception - old_payload = ChannelMetadataPayload.from_file(CHANNEL_METADATA) - payload = ChannelMetadataPayload.from_file(CHANNEL_METADATA_UPDATED) - payload.signature = old_payload.signature - self.assertRaises(InvalidSignatureException, self.session.lm.update_channel, payload) + self.assertEqual(6, channel2.local_version) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index ff7d3acd1f5..00b2d0fefc5 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -8,6 +8,7 @@ from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef @@ -79,21 +80,19 @@ def test_list_contents(self): """ Test whether a correct list with channel content is returned from the database """ - pub_key1 = default_eccrypto.generate_key('low').pub().key_to_bin() - pub_key2 = default_eccrypto.generate_key('low').pub().key_to_bin() + self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + channel1 = self.mds.ChannelMetadata() + self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) - channel1 = self.mds.ChannelMetadata(public_key=pub_key1) - self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, public_key=pub_key1)) - - channel2 = self.mds.ChannelMetadata(public_key=pub_key2) - self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, public_key=pub_key2)) - self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, public_key=pub_key2)) + self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + channel2 = self.mds.ChannelMetadata() + self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) + self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) self.assertEqual(1, len(channel1.contents_list)) self.assertEqual(2, len(channel2.contents_list)) self.assertEqual(2, channel2.contents_len) - @db_session def test_create_channel(self): """ @@ -136,9 +135,9 @@ def test_process_channel_metadata_payload(self): # Check that we always take the latest version channel_metadata.version -= 1 - self.assertEqual(channel_metadata.version, 2) + self.assertEqual(channel_metadata.version, 4) channel_metadata = self.mds.ChannelMetadata.process_channel_metadata_payload(payload) - self.assertEqual(channel_metadata.version, 3) + self.assertEqual(channel_metadata.version, 5) self.assertEqual(len(self.mds.ChannelMetadata.select()), 1) @db_session @@ -151,6 +150,14 @@ def test_get_dirname(self): self.assertEqual(len(channel_metadata.dir_name), 60) + @db_session + def test_get_channel_with_dirname(self): + sample_channel_dict = TestChannelMetadata.get_sample_channel_dict(self.my_key) + channel_metadata = self.mds.ChannelMetadata.from_dict(sample_channel_dict) + dirname = channel_metadata.dir_name + channel_result = self.mds.ChannelMetadata.get_channel_with_dirname(dirname) + self.assertEqual(channel_metadata, channel_result) + @db_session def test_get_channel_with_id(self): """ @@ -167,7 +174,7 @@ def test_add_metadata_to_channel(self): """ channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') self.mds.TorrentMetadata.from_dict( - dict(self.torrent_template, public_key=channel_metadata.public_key)) + dict(self.torrent_template, public_key=channel_metadata.public_key, status=NEW)) channel_metadata.commit_channel_torrent() self.assertEqual(channel_metadata.version, 1) @@ -219,8 +226,8 @@ def test_consolidate_channel_torrent(self): channel.commit_channel_torrent() # 2nd torrent - self.mds.TorrentMetadata.from_dict( - dict(self.torrent_template, public_key=channel.public_key)) + md = self.mds.TorrentMetadata.from_dict( + dict(self.torrent_template, public_key=channel.public_key, status=NEW)) channel.commit_channel_torrent() # Delete entry diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 7d29a637460..989ed814ef3 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -7,6 +7,7 @@ from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import (ChannelMetadataPayload, MetadataPayload, UnknownBlobTypeException) from Tribler.Core.Modules.MetadataStore.store import MetadataStore @@ -28,7 +29,7 @@ class TestMetadataStore(TriblerCoreTest): """ DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(os.path.realpath(__file__))), '..', '..', 'data') CHANNEL_DIR = os.path.join(DATA_DIR, 'sample_channel', - 'd24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46') + '4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6') CHANNEL_METADATA = os.path.join(DATA_DIR, 'sample_channel', 'channel.mdblob') @inlineCallbacks @@ -92,7 +93,7 @@ def test_multiple_squashed_commit_and_read(self): num_entries = 10 channel = self.mds.ChannelMetadata(title='testchan') - md_list = [self.mds.TorrentMetadata(title='test' + str(x)) for x in xrange(0, num_entries)] + md_list = [self.mds.TorrentMetadata(title='test' + str(x), status=NEW) for x in range(0, num_entries)] channel.commit_channel_torrent() channel.local_version = 0 @@ -114,4 +115,4 @@ def test_process_channel_dir(self): self.assertFalse(channel.contents_list) self.mds.process_channel_dir(self.CHANNEL_DIR, channel.public_key) self.assertEqual(len(channel.contents_list), 3) - self.assertEqual(channel.local_version, 3) + self.assertEqual(channel.local_version, 5) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 84eced82ce0..6aa39497834 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -27,6 +27,7 @@ def setUp(self): self.my_key = default_eccrypto.generate_key(u"curve25519") self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, self.my_key) + @inlineCallbacks def tearDown(self): self.mds.shutdown() @@ -47,7 +48,7 @@ def test_get_magnet(self): """ torrent_metadata = self.mds.TorrentMetadata.from_dict({}) self.assertTrue(torrent_metadata.get_magnet()) - torrent_metadata2 = self.mds.TorrentMetadata.from_dict({'title':u'\U0001f4a9'}) + torrent_metadata2 = self.mds.TorrentMetadata.from_dict({'title': u'\U0001f4a9'}) self.assertTrue(torrent_metadata2.get_magnet()) @db_session @@ -65,28 +66,28 @@ def test_search_keyword(self): dict(self.torrent_template, title="xoxoxo bar", tags="audio")) # Search for torrents with the keyword 'foo', it should return one result - results = self.mds.TorrentMetadata.search_keyword("foo") + results = self.mds.TorrentMetadata.search_keyword("foo")[:] self.assertEqual(len(results), 1) self.assertEqual(results[0].rowid, torrent1.rowid) # Search for torrents with the keyword 'eee', it should return one result - results = self.mds.TorrentMetadata.search_keyword("eee") + results = self.mds.TorrentMetadata.search_keyword("eee")[:] self.assertEqual(len(results), 1) self.assertEqual(results[0].rowid, torrent2.rowid) # Search for torrents with the keyword '123', it should return two results - results = self.mds.TorrentMetadata.search_keyword("123") + results = self.mds.TorrentMetadata.search_keyword("123")[:] self.assertEqual(len(results), 2) # Search for torrents with the keyword 'video', it should return three results - results = self.mds.TorrentMetadata.search_keyword("video") + results = self.mds.TorrentMetadata.search_keyword("video")[:] self.assertEqual(len(results), 3) def test_search_empty_query(self): """ Test whether an empty query returns nothing """ - self.assertFalse(self.mds.TorrentMetadata.search_keyword(None)) + self.assertFalse(self.mds.TorrentMetadata.search_keyword(None)[:]) @db_session def test_unicode_search(self): @@ -94,7 +95,7 @@ def test_unicode_search(self): Test searching in the database with unicode characters """ self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, title=u"Ñ Ð¼Ð°Ð»ÐµÐ½ÑŒÐºÐ¸Ð¹ апельÑин")) - results = self.mds.TorrentMetadata.search_keyword(u"маленький") + results = self.mds.TorrentMetadata.search_keyword(u"маленький")[:] self.assertEqual(1, len(results)) @db_session @@ -104,9 +105,9 @@ def test_wildcard_search(self): """ self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, title="foobar 123")) self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, title="foobla 123")) - self.assertEqual(0, len(self.mds.TorrentMetadata.search_keyword("*"))) - self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("foobl*"))) - self.assertEqual(2, len(self.mds.TorrentMetadata.search_keyword("foo*"))) + self.assertEqual(0, len(self.mds.TorrentMetadata.search_keyword("*")[:])) + self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("foobl*")[:])) + self.assertEqual(2, len(self.mds.TorrentMetadata.search_keyword("foo*")[:])) @db_session def test_stemming_search(self): @@ -117,11 +118,11 @@ def test_stemming_search(self): dict(self.torrent_template, title="mountains sheep", tags="video")) # Search with the word 'mountain' should return the torrent with 'mountains' in the title - results = self.mds.TorrentMetadata.search_keyword("mountain") + results = self.mds.TorrentMetadata.search_keyword("mountain")[:] self.assertEqual(torrent.rowid, results[0].rowid) # Search with the word 'sheeps' should return the torrent with 'sheep' in the title - results = self.mds.TorrentMetadata.search_keyword("sheeps") + results = self.mds.TorrentMetadata.search_keyword("sheeps")[:] self.assertEqual(torrent.rowid, results[0].rowid) @db_session diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py index 3090671db3b..bbf83977620 100644 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py @@ -1,18 +1,21 @@ from __future__ import absolute_import -from binascii import hexlify import json import os +import random +from binascii import hexlify import six from pony.orm import db_session from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG +from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import ALREADY_SUBSCRIBED_RESPONSE_MSG from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ AbstractTestChantEndpoint from Tribler.Test.test_as_server import TESTS_DATA_DIR from Tribler.Test.tools import trial_timeout +from Tribler.pyipv8.ipv8.database import database_blob class TestChannelsDiscoveredEndpoints(AbstractTestChannelsEndpoint): @@ -42,20 +45,6 @@ def test_get_channel_info(self): class TestChannelsDiscoveredChantEndpoints(AbstractTestChantEndpoint): - @trial_timeout(10) - def test_get_discovered_chant_channel(self): - """ - Test whether we successfully retrieve a discovered chant channel - """ - - def verify_response(response): - json_response = json.loads(response) - self.assertTrue(json_response['channels']) - - self.create_my_channel('test', 'test') - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=200).addCallback(verify_response) - @trial_timeout(10) def test_create_my_channel(self): """ @@ -112,3 +101,109 @@ def test_export_channel_mdblob_notfound(self): self.should_check_equality = False return self.do_request('channels/discovered/%s/mdblob' % hexlify(payload.public_key), expected_code=404, request_type='GET') + + @trial_timeout(10) + def test_subscribe_channel_already_subscribed(self): + """ + Testing whether the API returns error 409 when subscribing to an already subscribed channel + """ + with db_session: + channel = self.add_random_channel() + channel.subscribed = True + channel_public_key = channel.public_key + expected_json = {"error": ALREADY_SUBSCRIBED_RESPONSE_MSG} + + return self.do_request('channels/subscribed/%s' % str(channel_public_key).encode('hex'), + expected_code=409, expected_json=expected_json, request_type='PUT') + + @trial_timeout(10) + def test_remove_single_torrent(self): + """ + Testing whether the API can remove a single selected torrent from a channel + """ + with db_session: + channel = self.create_my_channel("bla", "bla") + channel_public_key = channel.public_key + torrent = self.add_random_torrent_to_my_channel() + torrent_infohash = torrent.infohash + + def verify_torrent_removed(response): + json_response = json.loads(response) + self.assertTrue(json_response["removed"], "Removing selected torrents failed") + with db_session: + self.assertEqual(len(channel.contents[:]), 0) + + self.should_check_equality = False + url = 'channels/discovered/%s/torrents/%s' % (hexlify(str(channel_public_key)), hexlify(str(torrent_infohash))) + + return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) + + @trial_timeout(10) + def test_remove_multiple_torrents(self): + """ + Testing whether the API can remove multiple selected torrents from a channel + """ + with db_session: + channel = self.create_my_channel("bla", "bla") + channel_public_key = channel.public_key + torrent1 = self.add_random_torrent_to_my_channel() + torrent2 = self.add_random_torrent_to_my_channel() + torrent1_infohash = torrent1.infohash + torrent2_infohash = torrent2.infohash + + def verify_torrent_removed(response): + json_response = json.loads(response) + self.assertTrue(json_response["removed"], "Removing selected torrents failed") + with db_session: + self.assertEqual(len(channel.contents[:]), 0) + + self.should_check_equality = False + url = 'channels/discovered/%s/torrents/%s' % (str(channel_public_key).encode('hex'), + str(torrent1_infohash).encode('hex') + ',' + str( + torrent2_infohash).encode('hex')) + + return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) + + @trial_timeout(10) + def test_remove_wrong_channel(self): + """ + Testing whether the API returns correct error message in case the channel public key is wrong + """ + with db_session: + self.create_my_channel("bla", "bla") + url = 'channels/discovered/%s/torrents/%s' % (hexlify('123'), hexlify(str('123'))) + return self.do_request(url, expected_code=405, request_type='DELETE') + + @trial_timeout(10) + def test_remove_nonexistent_channel(self): + """ + Testing whether the API returns correct error message in case the personal channel is not created yet + """ + with db_session: + channel = self.create_my_channel("bla", "bla") + channel_pubkey = channel.public_key + channel.delete() + url = 'channels/discovered/%s/torrents/%s' % (hexlify(str(channel_pubkey)), hexlify(str('123'))) + return self.do_request(url, expected_code=404, request_type='DELETE') + + @trial_timeout(10) + def test_remove_unknown_infohash(self): + """ + Testing whether the API returns {"removed": False, "failed_torrents":[ infohash ]} if an unknown torrent is + removed from a channel + """ + with db_session: + channel = self.create_my_channel("bla", "bla") + channel_public_key = channel.public_key + unknown_torrent_infohash = database_blob(bytearray(random.getrandbits(8) for _ in range(20))) + + def verify_torrent_removed(response): + json_response = json.loads(response) + self.assertFalse(json_response["removed"], "Tribler removed an unknown torrent") + self.assertTrue(str(unknown_torrent_infohash).encode('hex') in json_response["failed_torrents"]) + + self.should_check_equality = False + url = 'channels/discovered/%s/torrents/%s' % ( + str(channel_public_key).encode('hex'), str(unknown_torrent_infohash).encode('hex')) + + return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py index 5f9bc7d9156..0b0379e6a16 100644 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py @@ -1,9 +1,12 @@ from __future__ import absolute_import + +import random + from pony.orm import db_session from six.moves import xrange from twisted.internet.defer import inlineCallbacks -import Tribler.Core.Utilities.json_util as json +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.channel.channel import ChannelObject from Tribler.Core.Modules.channel.channel_manager import ChannelManager from Tribler.Core.exceptions import DuplicateChannelNameError @@ -42,7 +45,6 @@ def setUpPreSession(self): super(AbstractTestChantEndpoint, self).setUpPreSession() self.config.set_libtorrent_enabled(True) self.config.set_chant_enabled(True) - self.config.set_chant_channel_edit(True) @db_session def create_my_channel(self, name, description): @@ -56,8 +58,9 @@ def add_random_torrent_to_my_channel(self, name=None): """ Add a random torrent to your channel. """ - return self.session.lm.mds.TorrentMetadata(title='test' if not name else name, - infohash='a' * 20) + return self.session.lm.mds.TorrentMetadata(status=NEW, title='test' if not name else name, + infohash=database_blob( + bytearray(random.getrandbits(8) for _ in xrange(20)))) @db_session def add_random_channel(self): @@ -67,6 +70,7 @@ def add_random_channel(self): """ rand_key = default_eccrypto.generate_key('low') new_channel = self.session.lm.mds.ChannelMetadata( + sign_with=rand_key, public_key=database_blob(rand_key.pub().key_to_bin()), title='test', tags='test') new_channel.sign(rand_key) return new_channel @@ -117,31 +121,3 @@ def test_channels_unknown_endpoint(self): """ self.should_check_equality = False return self.do_request('channels/thisendpointdoesnotexist123', expected_code=404) - - @trial_timeout(10) - def test_get_discovered_channels_no_channels(self): - """ - Testing whether the API returns no channels when fetching discovered channels - and there are no channels in the database - """ - expected_json = {u'channels': []} - return self.do_request('channels/discovered', expected_code=200, expected_json=expected_json) - - @trial_timeout(10) - def test_get_discovered_channels(self): - """ - Testing whether the API returns inserted channels when fetching discovered channels - """ - self.should_check_equality = False - for i in xrange(0, 10): - self.insert_channel_in_db('rand%d' % i, 42 + i, 'Test channel %d' % i, 'Test description %d' % i) - self.insert_channel_in_db('randbad', 100, 'badterm', 'Test description bad') - - def verify_channels(channels): - channels_json = json.loads(channels)['channels'] - self.assertEqual(len(channels_json), 10) - channels_json = sorted(channels_json, key=lambda channel: channel['name']) - for ind in xrange(len(channels_json)): - self.assertEqual(channels_json[ind]['name'], 'Test channel %d' % ind) - - return self.do_request('channels/discovered', expected_code=200).addCallback(verify_channels) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py index 8a575f8d3a3..62fe04421d2 100644 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py @@ -1,18 +1,18 @@ from __future__ import absolute_import -from binascii import hexlify import time +from binascii import hexlify -from pony.orm import db_session import six +from pony.orm import db_session from six.moves import xrange from twisted.internet.defer import succeed, fail, inlineCallbacks from twisted.python.failure import Failure -from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE, VOTE_UNSUBSCRIBE +from Tribler.Core.Modules.restapi import VOTE_UNSUBSCRIBE from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG -from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import ALREADY_SUBSCRIBED_RESPONSE_MSG, \ - NOT_SUBSCRIBED_RESPONSE_MSG, ChannelsModifySubscriptionEndpoint +from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import NOT_SUBSCRIBED_RESPONSE_MSG, \ + ChannelsModifySubscriptionEndpoint from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ AbstractTestChantEndpoint from Tribler.Test.tools import trial_timeout @@ -47,33 +47,6 @@ def on_dispersy_create_votecast(self, cid, vote, _): self.create_votecast_called = True return succeed(None) - @trial_timeout(10) - def test_subscribe_channel_already_subscribed(self): - """ - Testing whether the API returns error 409 when subscribing to an already subscribed channel - """ - cid = self.insert_channel_in_db('rand1', 42, 'Test channel', 'Test description') - self.vote_for_channel(cid, int(time.time())) - expected_json = {"error": ALREADY_SUBSCRIBED_RESPONSE_MSG} - - return self.do_request('channels/subscribed/%s' % hexlify(b'rand1'), - expected_code=409, expected_json=expected_json, request_type='PUT') - - @trial_timeout(10) - def test_subscribe_channel(self): - """ - Testing whether the API creates a request in the AllChannel community when subscribing to a channel - """ - - def verify_votecast_made(_): - self.assertTrue(self.create_votecast_called) - - expected_json = {"subscribed": True} - self.expected_votecast_cid = 'rand1' - self.expected_votecast_vote = VOTE_SUBSCRIBE - return self.do_request('channels/subscribed/%s' % hexlify(b'rand1'), expected_code=200, - expected_json=expected_json, request_type='PUT').addCallback(verify_votecast_made) - @trial_timeout(10) def test_sub_channel_throw_error(self): """ diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py index a4b9397c4c4..b8cb46da8c9 100644 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py @@ -1,64 +1,26 @@ +from __future__ import absolute_import + import base64 import os import shutil import urllib from pony.orm import db_session - -from Tribler.Core.exceptions import HttpError -from Tribler.Test.tools import trial_timeout from twisted.internet.defer import inlineCallbacks -from Tribler.Core.TorrentDef import TorrentDef import Tribler.Core.Utilities.json_util as json +from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.network_utils import get_random_port +from Tribler.Core.exceptions import HttpError from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ AbstractTestChantEndpoint from Tribler.Test.Core.base_test import MockObject from Tribler.Test.common import TORRENT_UBUNTU_FILE -from Tribler.dispersy.exception import CommunityNotFoundException -from Tribler.Test.Core.Modules.MetadataStore.test_channel_download import CHANNEL_DIR, CHANNEL_METADATA +from Tribler.Test.tools import trial_timeout class TestChannelTorrentsEndpoint(AbstractTestChannelsEndpoint): - @trial_timeout(10) - def test_get_torrents_in_channel_invalid_cid(self): - """ - Testing whether the API returns error 404 if a non-existent channel is queried for torrents - """ - self.should_check_equality = False - return self.do_request('channels/discovered/abcd/torrents', expected_code=404) - - @trial_timeout(15) - @inlineCallbacks - def test_get_torrents_in_channel(self): - """ - Testing whether the API returns inserted torrents when fetching discovered channels, with and without filter - """ - def verify_torrents_filter(torrents): - torrents_json = json.loads(torrents) - self.assertEqual(len(torrents_json['torrents']), 1) - self.assertEqual(torrents_json['torrents'][0]['infohash'], 'a' * 40) - - def verify_torrents_no_filter(torrents): - torrents_json = json.loads(torrents) - self.assertEqual(len(torrents_json['torrents']), 2) - - self.should_check_equality = False - channel_id = self.insert_channel_in_db('rand', 42, 'Test channel', 'Test description') - - torrent_list = [ - [channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []], - [channel_id, 1, 1, ('b' * 40).decode('hex'), 1460000000, "badterm", [['file1.txt', 42]], []] - ] - self.insert_torrents_into_channel(torrent_list) - - yield self.do_request('channels/discovered/%s/torrents' % 'rand'.encode('hex'), expected_code=200)\ - .addCallback(verify_torrents_filter) - yield self.do_request('channels/discovered/%s/torrents?disable_filter=1' % 'rand'.encode('hex'), - expected_code=200).addCallback(verify_torrents_no_filter) - @trial_timeout(10) def test_add_torrent_to_channel(self): """ @@ -261,8 +223,8 @@ def verify_error_message(body): torrent_url = 'magnet:fake' url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(torrent_url)) self.should_check_equality = False - return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT')\ - .addCallback(verify_error_message) + return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT') \ + .addCallback(verify_error_message) @trial_timeout(10) def test_timeout_on_add_torrent(self): @@ -290,172 +252,12 @@ def verify_error_message(body): torrent_url = 'magnet:fake' url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(torrent_url)) self.should_check_equality = False - return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT')\ - .addCallback(verify_error_message) - - @trial_timeout(10) - def test_remove_tor_unknown_channel(self): - """ - Testing whether the API returns an error 500 if a torrent is removed from an unknown channel - """ - return self.do_request('channels/discovered/abcd/torrents/abcd', expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_remove_tor_unknown_infohash(self): - """ - Testing whether the API returns {"removed": False, "failed_torrents":[ infohash ]} if an unknown torrent is - removed from a channel - """ - unknown_torrent_infohash = 'a' * 40 - - mock_channel_community = MockObject() - mock_channel_community.called_remove = False - - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - - self.create_fake_channel("channel", "") - self.session.get_dispersy_instance = lambda: mock_dispersy - - def verify_delete_response(response): - json_response = json.loads(response) - self.assertFalse(json_response["removed"], "Tribler removed an unknown torrent") - self.assertTrue(unknown_torrent_infohash in json_response["failed_torrents"]) - self.assertFalse(mock_channel_community.called_remove) - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), unknown_torrent_infohash) - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_delete_response) - - @trial_timeout(10) - def test_remove_tor_unknown_cmty(self): - """ - Testing whether the API returns an error 500 if torrent is removed from a channel without community - """ - channel_id = self.create_fake_channel("channel", "") - torrent_list = [[channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40) - return self.do_request(url, expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_remove_torrent(self): - """ - Testing whether the API can remove a torrent from a channel - """ - mock_channel_community = MockObject() - mock_channel_community.called_remove = False - - def verify_torrent_removed(_): - self.assertTrue(mock_channel_community.called_remove) - - channel_id = self.create_fake_channel("channel", "") - torrent_list = [[channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - - def remove_torrents_called(_): - mock_channel_community.called_remove = True - - mock_channel_community.remove_torrents = remove_torrents_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40) - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) - - @trial_timeout(10) - def test_remove_selected_torrents(self): - """ - Testing whether the API can remove selected torrents from a channel - """ - mock_channel_community = MockObject() - mock_channel_community.called_remove = False - - def remove_torrents_called(_): - mock_channel_community.called_remove = True - - mock_channel_community.remove_torrents = remove_torrents_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - - channel_id = self.create_fake_channel("channel", "") - self.session.get_dispersy_instance = lambda: mock_dispersy - - torrent_list = [[channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []], - [channel_id, 1, 1, ('b' * 40).decode('hex'), 1460002000, "ubuntu-torrent2.iso", - [['file2.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - - def verify_torrent_removed(response): - json_response = json.loads(response) - self.assertTrue(json_response["removed"], "Removing selected torrents failed") - self.assertTrue(mock_channel_community.called_remove) - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40 + "," + 'b' * 40) - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) + return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT') \ + .addCallback(verify_error_message) class TestChannelTorrentsChantEndpoint(AbstractTestChantEndpoint): - @trial_timeout(10) - def test_get_torrents_unknown_channel(self): - """ - Test whether querying torrents in an unknown chant channel with the API results in an error - """ - return self.do_request('channels/discovered/%s/torrents' % ('a' * (74 * 2)), expected_code=404) - - @trial_timeout(10) - def test_get_torrents_from_my_channel(self): - """ - Test whether the API returns the correct torrents from our chant channel - """ - def verify_response(response): - json_response = json.loads(response) - self.assertEqual(len(json_response['torrents']), 1) - self.assertEqual(json_response['torrents'][0]['name'], 'forthetest') - - my_channel = self.create_my_channel('test', 'test') - self.add_random_torrent_to_my_channel(name='forthetest') - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents' % str(my_channel.public_key).encode('hex'), - expected_code=200).addCallback(verify_response) - - @trial_timeout(10) - def test_get_torrents_from_channel(self): - """ - Test whether the API returns the correct torrents from other's chant channel - """ - - with db_session: - channel = self.session.lm.mds.process_mdblob_file(CHANNEL_METADATA)[0] - public_key = channel.public_key - channel_dir = os.path.join(CHANNEL_DIR, channel.dir_name) - self.session.lm.mds.process_channel_dir(channel_dir, public_key) - channel_size = len(channel.contents_list) - - def verify_response(response): - json_response = json.loads(response) - self.assertEqual(len(json_response['torrents']), channel_size) - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents' % str(public_key).encode('hex'), - expected_code=200).addCallback(verify_response) - @trial_timeout(10) def test_add_torrent_to_external_channel(self): """ @@ -547,6 +349,7 @@ def test_add_magnet_to_channel(self): """ Test adding a magnet to a chant channel using the API """ + def fake_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): meta_info = TorrentDef.load(TORRENT_UBUNTU_FILE).get_metainfo() callback(meta_info) @@ -603,6 +406,7 @@ def test_remove_multiple_torrents_from_my_channel_fail(self): """ Test removing some torrents from your channel with the API, while that fails """ + def verify_response(response): json_response = json.loads(response) self.assertIn('failed_torrents', json_response) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_create_channel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_create_channel_endpoint.py deleted file mode 100644 index beedf2bf50b..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_create_channel_endpoint.py +++ /dev/null @@ -1,112 +0,0 @@ -import Tribler.Core.Utilities.json_util as json -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint -from Tribler.Test.tools import trial_timeout - - -class TestCreateChannelEndpoint(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_my_channel_endpoint_create(self): - """ - Testing whether the API returns the right JSON data if a channel is created - """ - - def verify_channel_created(body): - channel_obj = self.session.lm.channel_manager._channel_list[0] - self.assertEqual(channel_obj.name, post_data["name"]) - self.assertEqual(channel_obj.description, post_data["description"]) - self.assertEqual(channel_obj.mode, post_data["mode"]) - self.assertDictEqual(json.loads(body), {"added": channel_obj.channel_id}) - - post_data = { - "name": "John Smit's channel", - "description": "Video's of my cat", - "mode": "semi-open" - } - self.session.create_channel = self.create_fake_channel - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=200, expected_json=None, request_type='PUT', - post_data=post_data).addCallback(verify_channel_created) - - @trial_timeout(10) - def test_my_channel_endpoint_create_default_mode(self): - """ - Testing whether the API returns the right JSON data if a channel is created - """ - - def verify_channel_created(body): - channel_obj = self.session.lm.channel_manager._channel_list[0] - self.assertEqual(channel_obj.name, post_data["name"]) - self.assertEqual(channel_obj.description, post_data["description"]) - self.assertEqual(channel_obj.mode, u'closed') - self.assertDictEqual(json.loads(body), {"added": channel_obj.channel_id}) - - post_data = { - "name": "John Smit's channel", - "description": "Video's of my cat" - } - self.session.create_channel = self.create_fake_channel - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=200, expected_json=None, - request_type='PUT', post_data=post_data).addCallback(verify_channel_created) - - @trial_timeout(10) - def test_my_channel_endpoint_create_duplicate_name_error(self): - """ - Testing whether the API returns a formatted 500 error if DuplicateChannelNameError is raised - """ - - def verify_error_message(body): - error_response = json.loads(body) - expected_response = { - u"error": { - u"handled": True, - u"code": u"DuplicateChannelNameError", - u"message": u"Channel name already exists: %s" % post_data["name"] - } - } - self.assertDictContainsSubset(expected_response[u"error"], error_response[u"error"]) - - post_data = { - "name": "John Smit's channel", - "description": "Video's of my cat", - "mode": "semi-open" - } - self.session.create_channel = self.create_fake_channel_with_existing_name - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=500, expected_json=None, request_type='PUT', - post_data=post_data).addCallback(verify_error_message) - - @trial_timeout(10) - def test_my_channel_endpoint_create_no_name_param(self): - """ - Testing whether the API returns a 400 and error if the name parameter is not passed - """ - post_data = { - "description": "Video's of my cat", - "mode": "semi-open" - } - expected_json = {"error": "channel name cannot be empty"} - return self.do_request('channels/discovered', expected_code=400, expected_json=expected_json, - request_type='PUT', post_data=post_data) - - @trial_timeout(10) - def test_my_channel_endpoint_create_no_description_param(self): - """ - Testing whether the API returns the right JSON data if description parameter is not passed - """ - def verify_channel_created(body): - channel_obj = self.session.lm.channel_manager._channel_list[0] - self.assertEqual(channel_obj.name, post_data["name"]) - self.assertEqual(channel_obj.description, u'') - self.assertEqual(channel_obj.mode, post_data["mode"]) - self.assertDictEqual(json.loads(body), {"added": channel_obj.channel_id}) - - post_data = { - "name": "John Smit's channel", - "mode": "semi-open" - } - self.session.create_channel = self.create_fake_channel - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=200, expected_json=None, request_type='PUT', - post_data=post_data).addCallback(verify_channel_created) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py index 68a59d72156..3b6aade9b32 100644 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py +++ b/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py @@ -1,59 +1,18 @@ from __future__ import absolute_import from binascii import hexlify + import six from pony.orm import db_session from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.restapi.channels.my_channel_endpoint import NO_CHANNEL_CREATED_RESPONSE_MSG -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ - AbstractTestChantEndpoint +from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChantEndpoint from Tribler.Test.Core.base_test import MockObject from Tribler.Test.tools import trial_timeout -class TestMyChannelEndpoints(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_my_channel_overview_endpoint_no_my_channel(self): - """ - Testing whether the API returns response code 404 if no channel has been created - """ - expected_json = {"error": NO_CHANNEL_CREATED_RESPONSE_MSG} - return self.do_request('mychannel', expected_json=expected_json, expected_code=404) - - @trial_timeout(10) - def test_my_channel_overview_endpoint_with_channel(self): - """ - Testing whether the API returns the right JSON data if a channel overview is requested - """ - channel_json = {u'mychannel': {u'name': u'testname', u'description': u'testdescription', - u'identifier': six.text_type(hexlify(b'fakedispersyid'))}} - self.create_my_channel(channel_json[u'mychannel'][u'name'], channel_json[u'mychannel'][u'description']) - - return self.do_request('mychannel', expected_code=200, expected_json=channel_json) - - @trial_timeout(10) - def test_edit_my_channel_no_channel(self): - """ - Testing whether an error 404 is returned when trying to edit your non-existing channel - """ - post_params = {'name': 'test'} - return self.do_request('mychannel', expected_code=404, request_type='POST', post_data=post_params) - - @trial_timeout(10) - def test_edit_my_channel_no_cmty(self): - """ - Testing whether an error 404 is returned when trying to edit your channel without community - """ - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: None - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.create_my_channel('test', 'test') - - post_params = {'name': 'test'} - return self.do_request('mychannel', expected_code=404, request_type='POST', post_data=post_params) +class TestMyChannelChantEndpoints(AbstractTestChantEndpoint): @inlineCallbacks def test_edit_channel(self): @@ -86,9 +45,6 @@ def modify_channel_called(modifications): yield self.do_request('mychannel', expected_code=200, expected_json={"modified": True}, post_data=post_params, request_type='POST').addCallback(verify_channel_modified) - -class TestMyChannelChantEndpoints(AbstractTestChantEndpoint): - @trial_timeout(10) def test_my_channel_overview_endpoint_no_my_channel(self): """ @@ -102,8 +58,9 @@ def test_my_channel_overview_endpoint_with_channel(self): """ Testing whether the API returns the right JSON data if an existing chant channel overview is requested """ - channel_json = {u'mychannel': {u'chant': True, u'name': u'testname', u'description': u'testdescription', - u'identifier': hexlify(self.session.trustchain_keypair.pub().key_to_bin())}} + channel_json = {u'mychannel': {u'name': u'testname', u'description': u'testdescription', + u'identifier': six.text_type( + hexlify(self.session.trustchain_keypair.pub().key_to_bin()))}} self.create_my_channel(channel_json[u'mychannel'][u'name'], channel_json[u'mychannel'][u'description']) return self.do_request('mychannel', expected_code=200, expected_json=channel_json) diff --git a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py index 4d074682282..f9df2689c05 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py @@ -1,5 +1,5 @@ import os -from binascii import hexlify +from binascii import hexlify, unhexlify from urllib import pathname2url from pony.orm import db_session @@ -578,7 +578,7 @@ def verify_download(_): self.assertGreaterEqual(len(self.session.get_downloads()), 1) post_data = {'uri': 'file:%s' % os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')} - expected_json = {'started': True, 'infohash': '24eb2ff24c3a738eb1257a2fb4575db064848e25'} + expected_json = {'started': True, 'infohash': '02c146a1a3ffd96856a0319d1832cf70989e5a47'} return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, expected_json=expected_json).addCallback(verify_download) @@ -591,8 +591,9 @@ def test_add_metadata_download_invalid_sig(self): with open(file_path, "wb") as out_file: with db_session: my_channel = self.session.lm.mds.ChannelMetadata.create_channel('test', 'test') - my_channel.signature = "lalala" - out_file.write(my_channel.serialized()) + + hexed = hexlify(my_channel.serialized())[:-5] + "aaaaa" + out_file.write(unhexlify(hexed)) post_data = {'uri': 'file:%s' % file_path, 'metadata_download': '1'} expected_json = {'error': "Metadata has invalid signature"} diff --git a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py index ac8c7bc443d..d95492f81da 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py @@ -112,7 +112,6 @@ def send_notifications(_): self.session.notifier.notify(NTFY_NEW_VERSION, NTFY_INSERT, None, None) self.session.notifier.notify(NTFY_CHANNEL, NTFY_DISCOVERED, None, None) self.session.notifier.notify(NTFY_TORRENT, NTFY_DISCOVERED, None, {'a': 'Invalid character \xa1'}) - self.session.notifier.notify(NTFY_TORRENT, NTFY_DELETE, None, {'a': 'b'}) self.session.notifier.notify(NTFY_TORRENT, NTFY_FINISHED, 'a' * 10, None) self.session.notifier.notify(NTFY_TORRENT, NTFY_ERROR, 'a' * 10, 'This is an error message') self.session.notifier.notify(NTFY_MARKET_ON_ASK, NTFY_UPDATE, None, {'a': 'b'}) diff --git a/Tribler/Test/Core/Modules/RestApi/test_rest_manager.py b/Tribler/Test/Core/Modules/RestApi/test_rest_manager.py index 6406197bfde..0ef120870eb 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_rest_manager.py +++ b/Tribler/Test/Core/Modules/RestApi/test_rest_manager.py @@ -1,16 +1,18 @@ from __future__ import absolute_import + import os -from Tribler.Core.exceptions import TriblerException import Tribler.Core.Utilities.json_util as json -from Tribler.Test.tools import trial_timeout +from Tribler.Core.Modules.restapi.settings_endpoint import SettingsEndpoint +from Tribler.Core.exceptions import TriblerException from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.tools import trial_timeout -class RestRequestTest(AbstractApiTest): +def RaiseException(*args, **kwargs): + raise TriblerException(u"Oops! Something went wrong. Please restart Tribler") - def throw_unhandled_exception(self, name, description, mode=u'closed'): - raise TriblerException(u"Oops! Something went wrong. Please restart Tribler") +class RestRequestTest(AbstractApiTest): @trial_timeout(10) def test_unhandled_exception(self): @@ -29,15 +31,11 @@ def verify_error_message(body): } self.assertDictContainsSubset(expected_response[u"error"], error_response[u"error"]) - post_data = { - "name": "John Smit's channel", - "description": "Video's of my cat", - "mode": "semi-open" - } - self.session.create_channel = self.throw_unhandled_exception + post_data = json.dumps({"settings": "bla", "ports": "bla"}) + SettingsEndpoint.parse_settings_dict = RaiseException self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=500, expected_json=None, request_type='PUT', - post_data=post_data).addCallback(verify_error_message) + return self.do_request('settings', expected_code=500, raw_data=True, expected_json=None, request_type='POST', + post_data=post_data.encode('latin_1')).addCallback(verify_error_message) @trial_timeout(10) def test_tribler_shutting_down(self): diff --git a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py index 6a8234ccde3..74a1d597b4c 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py @@ -1,15 +1,18 @@ from __future__ import absolute_import -from six import unichr +import json +import random + from pony.orm import db_session +from six import unichr from six.moves import xrange from twisted.internet.defer import inlineCallbacks from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, NTFY_TORRENTS, SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, SIGNAL_TORRENT) -from Tribler.pyipv8.ipv8.database import database_blob from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest from Tribler.Test.tools import trial_timeout +from Tribler.pyipv8.ipv8.database import database_blob class FakeSearchManager(object): @@ -45,27 +48,10 @@ def setUp(self): self.channel_db_handler._get_my_dispersy_cid = lambda: "myfakedispersyid" self.torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - self.session.add_observer(self.on_search_results_channels, SIGNAL_CHANNEL, [SIGNAL_ON_SEARCH_RESULTS]) - self.session.add_observer(self.on_search_results_torrents, SIGNAL_TORRENT, [SIGNAL_ON_SEARCH_RESULTS]) - - self.results_torrents_called = False - self.results_channels_called = False - - self.search_results_list = [] # List of incoming torrent/channel results - self.expected_num_results_list = [] # List of expected number of results for each item in search_results_list - def setUpPreSession(self): super(TestSearchEndpoint, self).setUpPreSession() self.config.set_chant_enabled(True) - def on_search_results_torrents(self, subject, changetype, objectID, results): - self.search_results_list.append(results['result_list']) - self.results_torrents_called = True - - def on_search_results_channels(self, subject, changetype, objectID, results): - self.search_results_list.append(results['result_list']) - self.results_channels_called = True - def insert_channels_in_db(self, num): for i in xrange(0, num): self.channel_db_handler.on_channel_from_dispersy('rand%d' % i, 42 + i, @@ -73,70 +59,86 @@ def insert_channels_in_db(self, num): def insert_torrents_in_db(self, num): for i in xrange(0, num): - self.torrent_db_handler.addExternalTorrentNoDef(str(unichr(97 + i)) * 20, - 'Test %d' % i, [('Test.txt', 1337)], [], 1337) + ih = "".join(unichr(97 + random.randint(0, 15)) for _ in range(0, 20)) + self.torrent_db_handler.addExternalTorrentNoDef(ih.encode('utf-8'), 'hay %d' % i, [('Test.txt', 1337)], [], + 1337) @trial_timeout(10) - def test_search_no_parameter(self): + @inlineCallbacks + def test_search_legacy(self): """ - Testing whether the API returns an error 400 if no search query is passed with the request + Test a search query that should return a few new type channels """ - expected_json = {"error": "query parameter missing"} - return self.do_request('search', expected_code=400, expected_json=expected_json) - def verify_search_results(self, _): - self.assertTrue(self.results_channels_called) - self.assertTrue(self.results_torrents_called) - self.assertEqual(len(self.search_results_list), len(self.expected_num_results_list)) + self.insert_channels_in_db(1) + self.insert_torrents_in_db(100) + self.torrent_db_handler.addExternalTorrentNoDef(str(unichr(98)) * 20, 'Needle', [('Test.txt', 1337)], [], 1337) + self.should_check_equality = False - for ind in xrange(len(self.search_results_list)): - self.assertEqual(len(self.search_results_list[ind]), self.expected_num_results_list[ind]) + result = yield self.do_request('search?txt=needle', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 1) - @trial_timeout(10) - def test_search_no_matches(self): - """ - Testing whether the API finds no channels/torrents when searching if they are not in the database - """ - self.insert_channels_in_db(5) - self.insert_torrents_in_db(6) - self.expected_num_results_list = [0, 0] + result = yield self.do_request('search?txt=hay&first=10&last=20', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 10) - expected_json = {"queried": True} - return self.do_request('search?q=tribler', expected_code=200, expected_json=expected_json)\ - .addCallback(self.verify_search_results) - - @trial_timeout(10) - def test_search(self): """ - Testing whether the API finds channels/torrents when searching if there is some inserted data in the database + torrent_list = [ + [channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []], + [channel_id, 1, 1, ('b' * 40).decode('hex'), 1460000000, "badterm", [['file1.txt', 42]], []] + ] + self.insert_torrents_into_channel(torrent_list) """ - self.insert_channels_in_db(5) - self.insert_torrents_in_db(6) - self.expected_num_results_list = [5, 6, 0, 0] - - self.session.config.get_torrent_search_enabled = lambda: True - self.session.config.get_channel_search_enabled = lambda: True - self.session.lm.search_manager = FakeSearchManager(self.session.notifier) - - expected_json = {"queried": True} - return self.do_request('search?q=test', expected_code=200, expected_json=expected_json)\ - .addCallback(self.verify_search_results) @trial_timeout(10) + @inlineCallbacks def test_search_chant(self): """ Test a search query that should return a few new type channels """ - def verify_search_results(_): - self.assertTrue(self.search_results_list) + num_hay = 100 with db_session: my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - self.session.lm.mds.ChannelMetadata(public_key=database_blob(my_channel_id), title='test', tags='test') + channel = self.session.lm.mds.ChannelMetadata(public_key=database_blob(my_channel_id), title='test', + tags='test', subscribed=True) + for x in xrange(0, num_hay): + self.session.lm.mds.TorrentMetadata(title='hay ' + str(x), infohash=database_blob( + bytearray(random.getrandbits(8) for _ in xrange(20)))) + self.session.lm.mds.TorrentMetadata(title='needle', + infohash=database_blob( + bytearray(random.getrandbits(8) for _ in xrange(20)))) self.should_check_equality = False - self.expected_num_results_list = [] - return self.do_request('search?q=test', expected_code=200).addCallback(verify_search_results) + + result = yield self.do_request('search?txt=needle', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 1) + + result = yield self.do_request('search?txt=hay', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), num_hay) + + result = yield self.do_request('search?first=10&last=20', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 10) + + result = yield self.do_request('search?type=channel', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 1) + + result = yield self.do_request('search?sort_by=-name&type=torrent', expected_code=200) + parsed = json.loads(result) + self.assertEqual(parsed["torrents"][0][u'name'], 'needle') + + result = yield self.do_request('search?type=channel&subscribed=0', expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), 0) + + result = yield self.do_request('search?channel=%s' % str(channel.public_key).encode('hex'), expected_code=200) + parsed = json.loads(result) + self.assertEqual(len(parsed["torrents"]), num_hay + 1) @trial_timeout(10) def test_completions_no_query(self): diff --git a/Tribler/Test/Core/Modules/RestApi/test_util.py b/Tribler/Test/Core/Modules/RestApi/test_util.py index 2a8eaa29470..3e30618faf0 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_util.py +++ b/Tribler/Test/Core/Modules/RestApi/test_util.py @@ -1,5 +1,4 @@ # -*- coding:utf-8 -*- -import struct from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Modules.restapi.util import convert_search_torrent_to_json, convert_db_channel_to_json,\ @@ -46,8 +45,23 @@ def test_convert_torrent_to_json_tuple(self): Test whether the conversion from db torrent tuple to json works """ input_tuple = (1, '2', 'abc', 4, 5, 6, 7, 8, 0, 0.123) - output = {'id': 1, 'infohash': '2'.encode('hex'), 'name': 'abc', 'size': 4, 'category': 5, - 'num_seeders': 6, 'num_leechers': 7, 'last_tracker_check': 8, 'relevance_score': 0.123} + output = {'category': 5, + 'commit_status': 0, + 'date': 0, + 'dispersy_cid': '', + 'health': u'Seeds found', + 'id': 1, + 'infohash': '32', + 'last_tracker_check': 8, + 'name': 'abc', + 'num_leechers': 7, + 'num_seeders': 6, + 'public_key': '', + 'relevance_score': 0.123, + 'size': 4, + 'subscribed': '', + 'type': 'torrent', + 'votes': 0} self.assertEqual(convert_search_torrent_to_json(input_tuple), output) input_tuple = (1, '2', None, 4, 5, 6, 7, 8, 0, 0.123) diff --git a/Tribler/Test/Core/Utilities/test_utilities.py b/Tribler/Test/Core/Utilities/test_utilities.py index 04366de593a..92cb8523773 100644 --- a/Tribler/Test/Core/Utilities/test_utilities.py +++ b/Tribler/Test/Core/Utilities/test_utilities.py @@ -6,7 +6,7 @@ from twisted.web.util import Redirect from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.Utilities.utilities import http_get, is_valid_url, parse_magnetlink +from Tribler.Core.Utilities.utilities import parse_magnetlink, is_valid_url, http_get from Tribler.Test.test_as_server import AbstractServer from Tribler.Test.tools import trial_timeout diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob new file mode 100644 index 0000000000000000000000000000000000000000..ab88b10d6fff2ded248361fd666ee167111a47ea GIT binary patch literal 265 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxW`KeZb=<5iCDGNVO++}iy*I$m)oAyo>%4&|- zJ|%sj@!ycbl~$_3Pr{<>r>${qSQ~V@>dLLi)u#_Ubn(}0VePv#{e-D@B3JOI<2(S4 CdTXQr literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob new file mode 100644 index 0000000000000000000000000000000000000000..5e7d3aebb7d7bc358b7f5902c43e46aba917a55c GIT binary patch literal 561 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSs`s`{w!XD0}=^e0s$O zy9r6t_r>(?14$M2f)&V>CY9!ultACFHVPU zd$%Bdo_V0B)7on?Yg`^4*|h4x=M@_sJ!z~C*9&F-UvcM?5!<2JZx+eD@D1VxT1TT` z=<&M!^YycQ*#`e@OMcHTnU-UoLW?tT7(f4ZqT5} z$;>OQC@D%z&Q2}T%P&f|GBP(b&^I6e;2|}utnRB$Q0S&@_eEce|7m64B@h=8IDM{o u?gjyq^v~ZeF3xI<@~C;J*|Bua&DA!Km+=ibj&QGDw$j3vn=Dm^`?6NF zFRLv6!0v&wIbgi1d`Ylgfm2f7emSW%aq7j3W*G-vbn(W7dCT1b51DTt+<*{eL`rVVhJSbfl@+9ANOWv-O5d1eZ5yi2wiq literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob new file mode 100644 index 0000000000000000000000000000000000000000..96f48bd558ef5b8b43085150ae52bb2c3b1ba84f GIT binary patch literal 300 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSoZ*uH#BUAOQ3e^YZdb^O94=4i~nt{QP_9 z(d@QK-A?u^&U`xOFC`qiUT&gb=Dx|Z&sUem$(MXvrgtnxBFc0D-`gcW=Fikql$aTJ L`={$frE?qr$}Mwt literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob new file mode 100644 index 0000000000000000000000000000000000000000..3bea6dad6fccb56dc7a10b1236e4c8943d412a48 GIT binary patch literal 298 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOo`VSs`juiHOgKg*YG@ZYxN z_w15sIab>^K~m-dU z$~7g(R^+Bd;l8LFC))LVS7pquyCHNWxXSgb`GLPd`l0U8+C>tDl`aKks~w$RzBrVU IDgKTV0MDdxzW@LL literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/channel.mdblob b/Tribler/Test/Core/data/sample_channel/channel.mdblob index 5b7b3bc636192a87b6878fc43e03da396c29e875..94f8bf8ec8e949e2d09376c019a49e8eccd8d930 100644 GIT binary patch literal 230 zcmZQzU|{yiO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSoargKi5K|G$|Lw!m<% zgwgqe8S|ptp;F91nx~{FGbtyvD83}MxCF=-K<39Mx7np3ruKgbsD z+%wBTUSa!(G`>Y&`OJ)_dR`G%^!z#TD5vjvEf@Wqnlq{Kj1gHI0=2*2kzFsBsaax@ G#s~n)TUd(# literal 220 zcmZQzU|{yiO!7-~_6hK|;!sZd!u6s4%6Bh|MGOmX#O+VIdQ8=5O#svBH4JW_f&#Md zUh;HIvHw!|gtt8LxV)d0kK&T0FJ?0}I`c9<@A7l=+dkW$0SZ)J>wogGD(>5;TBW}w zJa$7$OP?xKiWx`?q-UllCTAq(<)!8*l%y7y0L7Tf452DO6!R9Z2PS*-1OHb2@ F9RNB@P3Zst diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index dc18fdeee63..4807a5b1fca 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1540204365e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e4612:piece lengthi16384e6:pieces20:Ô~€á «(ÑXlj>*`ÝR¡ee \ No newline at end of file +d13:creation datei1544611375e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi218e4:pathl19:000000000004.mdblobeed6:lengthi300e4:pathl19:000000000005.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb612:piece lengthi16384e6:pieces20:ç¢'U²2mðŽUú(±®7°Þee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index 42aa539de92cca2eaf08c56d3b0d5e330e97c18d..ae069bbf3c564aeb35ace424a417c02e832311d6 100644 GIT binary patch literal 230 zcmZQzU|{yiO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOo`VSs`&Y7==|`q%C?()la- zW4m+YwkvZ_K&4oKG*3xUW>QXSQG7{iaS4zwfXt6i%1MN(1yO8rIqqdDg+?Cnj;AiL zF5MnecJ}wN!cv|k_u?1rG`stWIcu-@ij=uaA3068ZR$wr=I)GseN|`0f5Gqvv#gd> HiZKEJfks^| literal 220 zcmZQzU|{yiO!7-~_6hK|;!sZd!u6s4%6Bh|MGOmX#O+VIdQ8=5O#svBH4JW_f&#Md zUh;HIvHw!|gtt8LxV)d0kK&T0FJ?0}I`c9<@A7l=+deyx0ScbHYhurlW6_g2IQM0w z)r$x01-BHTQY=7PAU!iZF*zeKFE2Gmp(M4q1SrN-W(-vUqF7!Qulc!6=+OZd#dmU3 z`s_CTx0r41lKa8!1~WUS(}F9diGTg4eq;-3Zt7)H=+--4Y7-D}=wxV#w2+&&&ATaI F_yB-sPznG5 diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent index 57ddbe0b2ea..b2af1e71af4 100644 --- a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent @@ -1 +1 @@ -d13:creation datei1540204617e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi300e4:pathl19:000000000004.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e4612:piece lengthi16384e6:pieces20:ðF•)NÞ#d°„ÚÓŠŽT¯ee \ No newline at end of file +d13:creation datei1544612327e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi298e4:pathl19:000000000006.mdblobeed6:lengthi218e4:pathl19:000000000004.mdblobeed6:lengthi300e4:pathl19:000000000005.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb612:piece lengthi16384e6:pieces20:v_Sf• úÆVѻŠË÷Pï¦ZÆee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000001.mdblob b/Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000001.mdblob deleted file mode 100644 index 1312de5a5e8e64c69ece53e6dbe280807ba3d03a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmZQzU|{meO!7-~_6hK|;!sZd!u6s4%6Bh|MGOmX#O+VIdQ8=5O#svBH4JW_f&#Md zUh;HIvHw!|gtt8LxV)d0kK&T0FJ?0}I`c9<@A7l=+dkWm0SZ3UakI9RL|30Sec3SG zqu1nFoAL#aRAhexh*Bs`D$Oe?)ipHNGc?dM)J;h(&MwI>&`r!uF*DK2EY1fh0s{4n zl9B=|ef^UBqN3Ei5F+oOZ~*Wy!_I<i diff --git a/Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000003.mdblob b/Tribler/Test/Core/data/sample_channel/d24941643ff471e40d7761c71f4e3a4c21a4a5e89b0281430d01e78a4e46/000000000003.mdblob deleted file mode 100644 index 9bbc582f6430a960d701300e7bd47808632eec6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 561 zcmZQzU|{meO!7-~_6hK|;!sZd!u6s4%6Bh|MGOmX#O+VIdQ8=5O#svBH4JW_f&#Md zUh;HIvHw!|gtt8LxV)d0kK&T0FJ?0}I`c9<@A7l=+dkWm0Scb%o9DZu?D1pq=@lF7 zCL~SY7t^~BBvsT4Rv=fJRGL>(s%vPWXJDe6l3JWyl3$>kn44l|qL*2m4>d$Rqokz3 zN?*Suzo;lRuS5@`NiR7+*UHS&%u+uwFE76|FFEyJ`WN+fx5iI#In5grCDtl$-)=H_ zVbI=7r)rq)+}HHU>-C?cFXP%jo#%%$6aO8DbCYitNAyh0z9`keZX&RQj}>Sgb%Vhl z>H&rxuiHOgKg*YG@ZYxN_w15sIab>^L2~8-U=>P;U@$b$&B-iF)h$jfDoZWG2nsi7 zP~>Fhl~$A#B_?O57U|^|rCS-98ye^v5CHIynskG4l7t}htLo$X|NhgypEP+^+1lMU whqBXlw9RJcGyHOY#rnE(+ zzcwYz@!Ayvl8RaiR-jT*npB!sQmSicp=W5Io0M9TsGE{noL!P%pqrSRVrHV3S)30w z$}OX$q`*pFKPNM channel.local_version: - # The sent version is newer than the one we have, queue the download. - channel.infohash = truncated_channel.infohash - self.download_queue.append(truncated_channel.infohash) - # We don't update anything if the channel version is older than the one we know. + minimal_blob_size = 200 + maximum_payload_size = 1024 + max_entries = maximum_payload_size / minimal_blob_size - def update_from_download(self, download): - """ - Given a channel download, update the amount of votes. - - :param download: the channel download to inspect - :type download: LibtorrentDownloadImpl - :returns: None - """ - infohash = download.tdef.get_infohash() + # Choose some random entries and try to pack them into maximum_payload_size bytes + md_list = [] with db_session: - channel = self.tribler_session.lm.mds.ChannelMetadata.get_channel_with_infohash(infohash) - if channel: - channel.votes = download.get_num_connected_seeds_peers()[0] - else: - # We have an older version in our list, decide what to do with it - my_key_hex = str(self.tribler_session.lm.mds.my_key.pub().key_to_bin()).encode('hex') - dirname = my_key_hex[-CHANNEL_DIR_NAME_LENGTH:] - if download.tdef.get_name() != dirname or time() - download.tdef.get_creation_date() > 604800: - # This is not our channel or more than a week old version of our channel: delete it - self.logger.debug("Removing old channel version %s", infohash.encode('hex')) - self.tribler_session.remove_download(download) - - def download_completed(self, download): - """ - Callback for when a channel download finished. - - :param download: the channel download which completed - :type download: LibtorrentDownloadImpl - :returns: None - """ - if self.request_cache.has(u"channel-download-cache", 0): - self.request_cache.pop(u"channel-download-cache", 0) - self.update_from_download(download) - - def update_states(self, states_list): - """ - Callback for when the download states are updated in Tribler. - We still need to filter out the channel downloads from this list. + channel_l = self.metadata_store.ChannelMetadata.get_random_subscribed_channels(1)[:] + if not channel_l: + return + channel = channel_l[0] + # TODO: when the health table will be there, send popular torrents instead + md_list.append(channel) + md_list.extend(list(channel.get_random_torrents(max_entries - 1))) + blob = entries_to_chunk(md_list, maximum_payload_size)[0] if md_list else None + + # Send chosen entries to peer + if md_list: + auth = BinMemberAuthenticationPayload(self.my_peer.public_key.key_to_bin()).to_pack_list() + ersatz_payload = [('raw', blob)] + self.endpoint.send(peer.address, self._ez_pack(self._prefix, 1, [auth, ersatz_payload])) - :param states_list: the list of download states - :type states_list: [DownloadState] - :returns: None + def on_blob(self, source_address, data): """ - for ds in states_list: - if ds.get_download().dlconfig.get('download_defaults', 'channel_download'): - self.update_from_download(ds.get_download()) + Callback for when a MetadataBlob message comes in. - def fetch_next(self): + :param source_address: the peer that sent us the blob + :param data: payload raw data """ - If we have nothing to process right now, start downloading a new channel. + auth, remainder = self.serializer.unpack_to_serializables([BinMemberAuthenticationPayload, ], data[23:]) + signature_valid, remainder = self._verify_signature(auth, data) + blob = remainder[23:] - :returns: None - """ - if self.request_cache.has(u"channel-download-cache", 0): - return - if self.download_queue: - infohash = self.download_queue.pop(0) - if not self.tribler_session.has_download(infohash): - self._logger.info("Starting channel download with infohash %s", infohash.encode('hex')) - # Reserve the token - self.request_cache.add(ChannelDownloadCache(self.request_cache)) - # Start downloading this channel - with db_session: - channel = self.tribler_session.lm.mds.ChannelMetadata.get_channel_with_infohash(infohash) - finished_deferred = self.tribler_session.lm.download_channel(channel)[1] - finished_deferred.addCallback(self.download_completed) + if not signature_valid: + raise PacketDecodingError("Incoming packet %s has an invalid signature" % str(self.__class__)) + self.metadata_store.process_squashed_mdblob(blob) class GigaChannelTestnetCommunity(GigaChannelCommunity): diff --git a/Tribler/community/gigachannel/payload.py b/Tribler/community/gigachannel/payload.py deleted file mode 100644 index a398add060e..00000000000 --- a/Tribler/community/gigachannel/payload.py +++ /dev/null @@ -1,54 +0,0 @@ -from Tribler.pyipv8.ipv8.messaging.payload import Payload - - -class TruncatedChannelPayload(Payload): - """ - Small representation of a channel containing a: - - - 20 character infohash - - 64 character channel title (possibly truncated) - - 64 character public key (LibNaCLPK without "LibNaCLPK:" prefix) - - 8 byte channel version - - In total this message is 156 bytes. - """ - - format_list = ['20s', '64s', '64s', 'Q'] - - def __init__(self, infohash, title, public_key, version): - self.infohash = infohash - self.title = title - self.public_key = public_key - self.version = version - - def to_pack_list(self): - return [('20s', self.infohash), - ('64s', self.title), - ('64s', self.public_key), - ('Q', self.version)] - - @classmethod - def from_unpack_list(cls, infohash, title, public_key, version): - return cls(infohash, title, public_key, version) - - -class TruncatedChannelPlayloadBlob(Payload): - """ - Collection of TruncatedChannelPayloads. - - This message can fit from 1 up to 7 TruncatedChannelPayloads. - The size of this message is therefore from 156 up to 1092 bytes. - """ - - format_list = [TruncatedChannelPayload] - optional_format_list = [TruncatedChannelPayload] * 6 - - def __init__(self, payload_list): - self.payload_list = payload_list - - def to_pack_list(self): - return [('payload', payload) for payload in self.payload_list[:7]] - - @classmethod - def from_unpack_list(cls, *args): - return cls(args) diff --git a/Tribler/community/gigachannel/sync_strategy.py b/Tribler/community/gigachannel/sync_strategy.py index 31fb76b2e9f..ccf1ef2edd8 100644 --- a/Tribler/community/gigachannel/sync_strategy.py +++ b/Tribler/community/gigachannel/sync_strategy.py @@ -7,9 +7,7 @@ class SyncChannels(DiscoveryStrategy): """ Synchronization strategy for gigachannels. - On each tick we: - 1. Send a random peer some of our random subscribed channels. - 2. Check if we have any pending channels to download. + On each tick we send a random peer some of our random subscribed channels. """ def take_step(self, service_id=None): @@ -19,5 +17,3 @@ def take_step(self, service_id=None): if peers: peer = choice(peers) self.overlay.send_random_to(peer) - # Try to fetch pending channels - self.overlay.fetch_next() diff --git a/TriblerGUI/defs.py b/TriblerGUI/defs.py index 1915646f2c0..6c5321640e5 100644 --- a/TriblerGUI/defs.py +++ b/TriblerGUI/defs.py @@ -32,12 +32,7 @@ PAGE_EDIT_CHANNEL_OVERVIEW = 0 PAGE_EDIT_CHANNEL_SETTINGS = 1 PAGE_EDIT_CHANNEL_TORRENTS = 2 -PAGE_EDIT_CHANNEL_PLAYLISTS = 3 -PAGE_EDIT_CHANNEL_RSS_FEEDS = 4 -PAGE_EDIT_CHANNEL_PLAYLIST_EDIT = 5 -PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS = 6 -PAGE_EDIT_CHANNEL_PLAYLIST_MANAGE = 7 -PAGE_EDIT_CHANNEL_CREATE_TORRENT = 8 +PAGE_EDIT_CHANNEL_CREATE_TORRENT = 3 PAGE_SETTINGS_GENERAL = 0 PAGE_SETTINGS_CONNECTION = 1 @@ -92,11 +87,6 @@ STATUS_UNKNOWN = 1 STATUS_DEAD = 2 -# Torrent channel commit status -COMMITTED = 0 -UNCOMMITTED = 1 -TODELETE = 2 - # Tribler shutdown grace period in milliseconds SHUTDOWN_WAITING_PERIOD = 120000 diff --git a/TriblerGUI/dialogs/confirmationdialog.py b/TriblerGUI/dialogs/confirmationdialog.py index 2e7a8ade6f3..c426729fed1 100644 --- a/TriblerGUI/dialogs/confirmationdialog.py +++ b/TriblerGUI/dialogs/confirmationdialog.py @@ -1,7 +1,9 @@ from PyQt5 import uic + from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QSizePolicy, QSpacerItem + from TriblerGUI.defs import BUTTON_TYPE_NORMAL from TriblerGUI.dialogs.dialogcontainer import DialogContainer from TriblerGUI.utilities import get_ui_file_path @@ -9,10 +11,9 @@ class ConfirmationDialog(DialogContainer): - button_clicked = pyqtSignal(int) - def __init__(self, parent, title, main_text, buttons, show_input=False): + def __init__(self, parent, title, main_text, buttons, show_input=False, checkbox_text=None): DialogContainer.__init__(self, parent) uic.loadUi(get_ui_file_path('buttonsdialog.ui'), self.dialog_widget) @@ -23,12 +24,18 @@ def __init__(self, parent, title, main_text, buttons, show_input=False): self.dialog_widget.dialog_main_text_label.setText(main_text) self.dialog_widget.dialog_main_text_label.adjustSize() + self.checkbox = self.dialog_widget.checkbox if not show_input: self.dialog_widget.dialog_input.setHidden(True) else: self.dialog_widget.dialog_input.returnPressed.connect(lambda: self.button_clicked.emit(0)) + if not checkbox_text: + self.dialog_widget.checkbox.setHidden(True) + else: + self.dialog_widget.checkbox.setText(checkbox_text) + hspacer_left = QSpacerItem(1, 1, QSizePolicy.Expanding, QSizePolicy.Fixed) self.dialog_widget.dialog_button_container.layout().addSpacerItem(hspacer_left) diff --git a/TriblerGUI/event_request_manager.py b/TriblerGUI/event_request_manager.py index 54f729200d7..a202d3fa0f3 100644 --- a/TriblerGUI/event_request_manager.py +++ b/TriblerGUI/event_request_manager.py @@ -80,11 +80,7 @@ def on_read_data(self): if len(received_events) > 100: # Only buffer the last 100 events received_events.pop() - if json_dict["type"] == "search_result_channel": - self.received_search_result_channel.emit(json_dict["event"]["result"]) - elif json_dict["type"] == "search_result_torrent": - self.received_search_result_torrent.emit(json_dict["event"]["result"]) - elif json_dict["type"] == "tribler_started" and not self.emitted_tribler_started: + if json_dict["type"] == "tribler_started" and not self.emitted_tribler_started: self.tribler_started.emit() self.emitted_tribler_started = True elif json_dict["type"] == "new_version_available": diff --git a/TriblerGUI/images/check.svg b/TriblerGUI/images/check.svg new file mode 100644 index 00000000000..383b1a04f10 --- /dev/null +++ b/TriblerGUI/images/check.svg @@ -0,0 +1,54 @@ + + + + + + image/svg+xml + + + + + + + + diff --git a/TriblerGUI/images/minus.svg b/TriblerGUI/images/minus.svg new file mode 100644 index 00000000000..df2fc2a01ec --- /dev/null +++ b/TriblerGUI/images/minus.svg @@ -0,0 +1,69 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/TriblerGUI/images/plus.svg b/TriblerGUI/images/plus.svg new file mode 100644 index 00000000000..20c9f690bea --- /dev/null +++ b/TriblerGUI/images/plus.svg @@ -0,0 +1,45 @@ + +image/svg+xml \ No newline at end of file diff --git a/TriblerGUI/images/undo.svg b/TriblerGUI/images/undo.svg new file mode 100644 index 00000000000..2ed5c52936e --- /dev/null +++ b/TriblerGUI/images/undo.svg @@ -0,0 +1,50 @@ + +image/svg+xml \ No newline at end of file diff --git a/TriblerGUI/qt_resources/buttonsdialog.ui b/TriblerGUI/qt_resources/buttonsdialog.ui index a8e156276c3..9c4a7a57e86 100644 --- a/TriblerGUI/qt_resources/buttonsdialog.ui +++ b/TriblerGUI/qt_resources/buttonsdialog.ui @@ -166,6 +166,46 @@ padding: 4px; + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + color: #B5B5B5; + + + CheckBox + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + diff --git a/TriblerGUI/qt_resources/channel_list_item.ui b/TriblerGUI/qt_resources/channel_list_item.ui deleted file mode 100644 index 7e8eaa5df31..00000000000 --- a/TriblerGUI/qt_resources/channel_list_item.ui +++ /dev/null @@ -1,430 +0,0 @@ - - - Form - - - - 0 - 0 - 585 - 60 - - - - - 0 - 0 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - PointingHandCursor - - - Form - - - false - - - background-color: #666; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 14 - 20 - - - - - - - - 0 - - - 0 - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 10 - 7 - - - - - - - - 4 - - - - - - 0 - 0 - - - - - 58 - 18 - - - - - 58 - 18 - - - - border: 1px solid #B5B5B5; -border-radius: 9px; -color: #B5B5B5; -font-size: 12px; -background-color: transparent; - - - channel - - - Qt::AlignCenter - - - - - - - - 0 - 0 - - - - QLabel { -color: #eee; -border: none; -background-color: transparent; -font-size: 15px; -} - - - TextLabel - - - - - - - - - -1 - - - - - - 0 - 0 - - - - - 120 - 0 - - - - - 16777215 - 16777215 - - - - QLabel { -color: #B5B5B5; -border: none; -background-color: transparent; -font-size: 15px; -} - - - active 6 days ago • 143 items - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 7 - - - - - - - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 10000 - 16777215 - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 28 - 28 - - - - - 28 - 28 - - - - QPushButton { -border: none; -background-color: transparent; -} - - - - - - - ../images/subscribed_not.png../images/subscribed_not.png - - - - 18 - 18 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 2 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - color: #B5B5B5; -background-color: transparent; - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 20 - - - - - - - - - 0 - 0 - - - - - 28 - 28 - - - - - 28 - 28 - - - - QPushButton { -border: none; -background-color: transparent; -} - - - - - - - ../images/credit_mining_not.png../images/credit_mining_not.png - - - - 18 - 18 - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 14 - 20 - - - - - - - - - SubscriptionsWidget - QWidget -
TriblerGUI.widgets.subscriptionswidget.h
- 1 -
-
- - -
diff --git a/TriblerGUI/qt_resources/channel_torrent_list_item.ui b/TriblerGUI/qt_resources/channel_torrent_list_item.ui deleted file mode 100644 index f988f92b9be..00000000000 --- a/TriblerGUI/qt_resources/channel_torrent_list_item.ui +++ /dev/null @@ -1,628 +0,0 @@ - - - Form - - - - 0 - 0 - 585 - 60 - - - - - 0 - 0 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - ArrowCursor - - - Form - - - QWidget { -background-color: #666; -} - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 60 - 42 - - - - - 60 - 42 - - - - PO - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - 0 - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 7 - - - - - - - - 4 - - - - - - 0 - 0 - - - - - 0 - 18 - - - - - 200 - 18 - - - - border: 1px solid #B5B5B5; -border-radius: 9px; -color: #B5B5B5; -font-size: 12px; -background-color: transparent; -padding-left: 5px; -padding-right: 5px; - - - video - - - Qt::AlignCenter - - - - - - - - 0 - 0 - - - - color: #eee; -border: none; -background-color: transparent; -font-size: 15px; - - - TextLabel - - - - - - - - - 0 - - - - - - 0 - 0 - - - - - 120 - 0 - - - - - 120 - 16777215 - - - - color: #b5b5b5; -border: none; -background-color: transparent; -font-size: 13px; - - - 384MB (3 files) - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 10 - 10 - - - - - 10 - 10 - - - - background-color: orange; -border-radius: 5px; - - - - - - - - - 0 - 0 - - - - - 0 - 20 - - - - - 16777215 - 20 - - - - color: #b5b5b5; -border: none; -background-color: transparent; -font-size: 13px; -padding-left: 2px; -padding-bottom: 1px; - - - unknown health - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 7 - - - - - - - - - - - - color: #bbb; -border: none; -background-color: transparent; -font-size: 15px; - - - STATE - - - Qt::NoTextInteraction - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 5 - 0 - - - - - - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - background-color: transparent; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 28 - 28 - - - - - 28 - 28 - - - - border-radius: 14px; - - - - - - - - ../images/delete.png../images/delete.png - - - - 12 - 12 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - - - background-color: transparent; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 28 - 28 - - - - - 28 - 28 - - - - border-radius: 14px; -padding-left: 2px; - - - - - - - ../images/play.png../images/play.png - - - - 14 - 14 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 6 - 20 - - - - - - - - - 28 - 28 - - - - - 28 - 28 - - - - border-radius: 14px; - - - - - - - ../images/downloads.png../images/downloads.png - - - - 14 - 14 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 6 - 20 - - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 14 - 20 - - - - - - - - - CircleButton - QToolButton -
TriblerGUI.widgets.circlebutton.h
-
- - ThumbnailWidget - QLabel -
TriblerGUI.widgets.thumbnailwidget.h
-
-
- - -
diff --git a/TriblerGUI/qt_resources/channel_view.ui b/TriblerGUI/qt_resources/channel_view.ui new file mode 100644 index 00000000000..43043823975 --- /dev/null +++ b/TriblerGUI/qt_resources/channel_view.ui @@ -0,0 +1,1493 @@ + + + channel_view + + + + 0 + 0 + 830 + 536 + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + ArrowCursor + + + Form + + + false + + + background-color: #202020; + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + QSplitter::handle { background-color: #555; } + + + Qt::Horizontal + + + + + 0 + 0 + + + + + + + + + 0 + 0 + 0 + + + + + + + 32 + 32 + 32 + + + + + + + 255 + 255 + 255 + + + + + + + 246 + 246 + 245 + + + + + + + 119 + 119 + 118 + + + + + + + 159 + 159 + 157 + + + + + + + 0 + 0 + 0 + + + + + + + 255 + 255 + 255 + + + + + + + 0 + 0 + 0 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 0 + 0 + 0 + + + + + + + 246 + 246 + 245 + + + + + + + 255 + 255 + 220 + + + + + + + 0 + 0 + 0 + + + + + + + + + 0 + 0 + 0 + + + + + + + 32 + 32 + 32 + + + + + + + 255 + 255 + 255 + + + + + + + 246 + 246 + 245 + + + + + + + 119 + 119 + 118 + + + + + + + 159 + 159 + 157 + + + + + + + 0 + 0 + 0 + + + + + + + 255 + 255 + 255 + + + + + + + 0 + 0 + 0 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 0 + 0 + 0 + + + + + + + 246 + 246 + 245 + + + + + + + 255 + 255 + 220 + + + + + + + 0 + 0 + 0 + + + + + + + + + 119 + 119 + 118 + + + + + + + 32 + 32 + 32 + + + + + + + 255 + 255 + 255 + + + + + + + 246 + 246 + 245 + + + + + + + 119 + 119 + 118 + + + + + + + 159 + 159 + 157 + + + + + + + 119 + 119 + 118 + + + + + + + 255 + 255 + 255 + + + + + + + 119 + 119 + 118 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 0 + 0 + 0 + + + + + + + 238 + 238 + 236 + + + + + + + 255 + 255 + 220 + + + + + + + 0 + 0 + 0 + + + + + + + + ArrowCursor + + + Form + + + false + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 80 + + + + + 0 + 50 + + + + + QLayout::SetDefaultConstraint + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + 0 + 0 + + + + + 200 + 0 + + + + QLineEdit { +background-color: #eee; +border: none; +padding-left: 5px; +border-radius: 14px; +color: black; +} + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + false + + + + + + + + + false + + + color: #B5B5B5; + + + Your channel has uncommitted changes. + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + true + + + + 0 + 0 + + + + + 0 + 35 + + + + + + + + + 32 + 32 + 32 + + + + + + + 186 + 189 + 182 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + + + 32 + 32 + 32 + + + + + + + 186 + 189 + 182 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + + + 32 + 32 + 32 + + + + + + + 190 + 190 + 190 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + + Apply changes to your channel and publish the new version + + + false + + + Apply changes + + + false + + + false + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + + + + + + + 0 + 0 + + + + + 500 + 50 + + + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 85 + 87 + 83 + + + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 85 + 87 + 83 + + + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 32 + 32 + 32 + + + + + + + 145 + 145 + 145 + + + + + + + + QTableView { + border: none; + font-size: 13px; + outline: 0; + } + QTableView::item { + color: white; + height: 40px; + border-bottom: 1px solid #303030; + } + + +QTableView::item::hover { + background-color: rgba(255,255,255, 50); + } + + QHeaderView { + background-color: transparent; + } + QHeaderView::section { + background-color: transparent; + border: none; + color: #B5B5B5; + padding: 10px; + font-size: 14px; + border-bottom: 1px solid #303030; + } + QHeaderView::section:hover { + color: white; + } + QTableCornerButton::section { + background-color: transparent; + } + QHeaderView::section:up-arrow { + color: white; + } + QHeaderView::section:down-arrow { + color: white; + } + + + + + + + + + 16777215 + 50 + + + + + 8 + + + 8 + + + 8 + + + 8 + + + 8 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE SELECTED + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE ALL + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + ADD + + + + + + + + + + + + 0 + 0 + + + + QLabel { +color: white; +} +QTabWidget { +border: none; +background-color: #202020; +} +QTabBar::tab { + color: white; + background-color: #555; +} +QTabBar::tab:selected { + color: #555; + background-color: #777; +} + + + 0 + + + + background-color: #202020; + + + Details + + + + 0 + + + 12 + + + 12 + + + 12 + + + 12 + + + + + border: none; + + + true + + + + + 0 + 0 + 186 + 469 + + + + + Qt::AlignCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + font-weight: bold; + + + Name + + + + + + + + + + true + + + + + + + font-weight: bold; + + + Category + + + + + + + font-weight: bold; + + + Size + + + + + + + + + + + + + + + + + + + + + font-weight: bold; margin-top:5px + + + Health + + + + + + + Qt::AlignCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + + + PointingHandCursor + + + + EllipseButton{ + border: 1px solid #b5b5b5; + border-radius: 4px; + color: white + } + EllipseButton::hover{ + color: #333; + background-color:#c5c5c5; + } + + + + Re-check + + + + + + + + + + + + + + Files + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QTreeWidget { +border: none; +font-size: 13px; +} +QTreeWidget::item { +color: white; +border-bottom: 1px solid #303030; +} +QTreeWidget::item:hover { +background-color: #303030; +} +QTreeWidget::item::selected { +background-color: #444; +} +QHeaderView { +background-color: transparent; +} +QHeaderView::section { +background-color: transparent; +border: none; +color: #B5B5B5; +padding: 10px; +font-size: 14px; +border-bottom: 1px solid #303030; +} +QHeaderView::drop-down { +color: red; +} +QHeaderView::section:hover { +color: white; +} +QTableCornerButton::section { +background-color: transparent; +} + + + QAbstractItemView::NoSelection + + + 0 + + + 300 + + + false + + + true + + + + PATH + + + + + SIZE + + + + + + + + + Trackers + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QTreeWidget { +border: none; +font-size: 13px; +} +QTreeWidget::item { +color: white; +border-bottom: 1px solid #303030; +} +QTreeWidget::item:hover { +background-color: #303030; +} +QTreeWidget::item::selected { +background-color: #444; +} +QHeaderView { +background-color: transparent; +} +QHeaderView::section { +background-color: transparent; +border: none; +color: #B5B5B5; +padding: 10px; +font-size: 14px; +border-bottom: 1px solid #303030; +} +QHeaderView::drop-down { +color: red; +} +QHeaderView::section:hover { +color: white; +} +QTableCornerButton::section { +background-color: transparent; +} + + + 0 + + + + NAME + + + + + + + + + + + + + + EllipseButton + QToolButton +
TriblerGUI.widgets.ellipsebutton.h
+
+ + TorrentDetailsTabWidget + QTabWidget +
TriblerGUI.widgets.torrentdetailstabwidget.h
+ 1 +
+ + ChannelsTableView + QWidget +
TriblerGUI.widgets.lazytableview.h
+
+
+ + +
diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index ab9aa32d9b3..5f1b1c12cab 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -1050,7 +1050,7 @@ background-color: #e67300; - 12 + 14 @@ -1816,62 +1816,6 @@ padding-bottom: 4px; - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - PLAYLISTS - - - true - - - - - - - - 0 - 36 - - - - - 16777215 - 36 - - - - PointingHandCursor - - - - - - RSS FEEDS - - - true - - - @@ -1906,7 +1850,7 @@ font-size: 14px; 1 - 0 + 3 @@ -1929,7 +1873,7 @@ font-size: 14px; font-size: 14px; - <html><head/><body><p>Welcome to the management interface of your channel!</p><p>Here, you can change settings of you channel, manage your shared torrents, manage your playlists and add rss feeds which are periodically polled.</p></body></html> + <html><head/><body><p>Welcome to the management interface of your channel!</p><p>Here, you can change settings of you channel and manage your shared torrents.</p></body></html> true @@ -2312,1554 +2256,51 @@ font-size: 14px; QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - false - - - Your channel has uncommitted and/or deleted torrents. - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - true - - - - 0 - 0 - - - - - 0 - 35 - - - - - - - - - 32 - 32 - 32 - - - - - - - 186 - 189 - 182 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 186 - 189 - 182 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 190 - 190 - 190 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - Apply changes to your channel and publish the new version - - - false - - - Apply changes - - - false - - - false - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item:selected { -background-color: #404040; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -} - - - QAbstractItemView::ContiguousSelection - - - QAbstractItemView::ScrollPerPixel - - - false - - - - - - - - 8 - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE SELECTED - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE ALL - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - ADD - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item:selected { -background-color: #404040; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -} - - - QAbstractItemView::NoSelection - - - QAbstractItemView::ScrollPerPixel - - - - - - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - NEW PLAYLIST - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 14px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -padding-left: 14px; -height: 40px; -} -QTreeWidget::item::selected { -background-color: #404040;; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -padding-left: 20px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QTableCornerButton::section { -background-color: transparent; -} - - - false - - - QAbstractItemView::ContiguousSelection - - - QAbstractItemView::ScrollPerPixel - - - 0 - - - - RSS FEED URL - - - - - - - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE SELECTED - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - ADD - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REFRESH ALL - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - margin: 10px; - - - Please enter the details of your playlist below. - - - - - - - - QFormLayout::ExpandingFieldsGrow - - - - - color: #B5B5B5; - - - Playlist name - - - - - - - - - - Playlist name - - - - - - - color: #B5B5B5; - - - Playlist description - - - - - - - - - - Playlist description - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 26 - - - - - 16777215 - 26 - - - - PointingHandCursor - - - border-radius: 13px; -font-size: 14px; - - - CANCEL - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 26 - - - - - 6000 - 26 - - - - PointingHandCursor - - - border-radius: 13px; -font-size: 14px; -padding-left: 4px; -padding-right: 4px; - - - SAVE - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - Qt::Vertical - - - QSizePolicy::Minimum - - - - 20 - 2 - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 16 - 18 - - - - - 16 - 18 - - - - PointingHandCursor - - - border: none; -border-image: url(images/page_back.png) 0 0 0 0 stretch stretch; -background: none; - - - - - - - - - - margin: 10px; -font-size: 15px; -font-weight: bold; -margin-left: 5px; -color: white; - - - Torrents in playlist 'bla' - - - - - - - - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item:selected { -background-color: #404040; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -} - - - QAbstractItemView::NoSelection - - - - - - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 506 - 20 - - - - - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - MANAGE TORRENTS - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 16 - 18 - - - - - 16 - 18 - - - - PointingHandCursor - - - border: none; -border-image: url(images/page_back.png) 0 0 0 0 stretch stretch; -background: none; - - - - - - - - - - margin: 10px; -font-size: 15px; -font-weight: bold; -margin-left: 5px; -color: white; - - - Manage torrents in playlist 'bla' - - - - - - - - - - QListWidget { -background-color: #303030; -border: none; -} -QListWidget::item { -color: white; -} -QToolButton { -font-size: 16px; -border-radius: 13px; -color: white; -} - - - - - - - 6 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - 26 - 26 - - - - - 26 - 26 - - - - PointingHandCursor - - - - - - < - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 10 - - - - - - - - - 26 - 26 - - - - - 26 - 26 - - - - PointingHandCursor - - - - - - > - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - - - - QAbstractItemView::ExtendedSelection - - - QAbstractItemView::ScrollPerPixel - - - QAbstractItemView::ScrollPerPixel - - - - - - - QAbstractItemView::ExtendedSelection - - - QAbstractItemView::ScrollPerPixel - - - QAbstractItemView::ScrollPerPixel - - - - - - - Torrents in playlist - - - - - - - Torrents in channel but not in playlist - - - - - - - - - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 581 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - SAVE - - - - - + + + + 20 + 10 + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + @@ -4611,7 +3052,7 @@ color: #B5B5B5; - + 0 @@ -5005,7 +3446,7 @@ background-color: transparent; - + @@ -5832,25 +4273,7 @@ color: white; - - - - font-weight: bold; -color: white; - - - Beta features - - - - - - - Enable Channel 2.0 editing - - - - + margin-top: 2px; @@ -7661,6 +6084,12 @@ font-size: 12px; + + + 0 + 0 + + 0 @@ -7770,7 +6199,7 @@ margin: 10px; - + QListWidget::item:hover { background-color: #303030; @@ -7783,9 +6212,6 @@ border: none; border-top: 1px solid #555; } - - QAbstractItemView::ScrollPerPixel - @@ -9176,210 +7602,10 @@ background-color: #303030; - - - - - - - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 16 - 18 - - - - - 16 - 18 - - - - PointingHandCursor - - - border: none; -border-image: url(images/page_back.png) 0 0 0 0 stretch stretch; - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - color: #eee; -background-color: transparent; -font-size: 20px; -font-weight: bold; - - - My Playlist - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - font-size: 14px; -color: #B5B5B5; - - - 23 items - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item:selected { -background-color: #404040; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -border-top: 1px solid #555; -} - - - QAbstractItemView::ScrollPerPixel - + + + + @@ -9610,6 +7836,12 @@ font-weight:bold; + + + 0 + 0 + + 0 @@ -9711,23 +7943,7 @@ color: #B5B5B5; - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -border-top: 1px solid #555; -} - - - QAbstractItemView::ScrollPerPixel - - + @@ -12038,7 +10254,7 @@ QTabBar::tab:selected { } - 1 + 0 @@ -12618,7 +10834,7 @@ margin-top: 9px; QWidget { -background-color: #383838; +background-color: #383838; } QLabel { border: none; @@ -12702,7 +10918,7 @@ border: none; QWidget { -background-color: #383838; +background-color: #383838; } QLabel { border: none; @@ -12775,7 +10991,7 @@ border: none; QWidget { -background-color: #383838; +background-color: #383838; } QLabel { border: none; @@ -12998,17 +11214,17 @@ color: #eee; + + EllipseButton + QToolButton +
TriblerGUI.widgets.ellipsebutton.h
+
SubscriptionsWidget QWidget
TriblerGUI.widgets.subscriptionswidget.h
1
- - EllipseButton - QToolButton -
TriblerGUI.widgets.ellipsebutton.h
-
VideoPlayerPage QWidget @@ -13061,11 +11277,6 @@ color: #eee;
TriblerGUI.widgets.downloadspage.h
1
- - LazyLoadList - QListWidget -
TriblerGUI.widgets.lazyloadlist.h
-
SubscribedChannelsPage QWidget @@ -13089,24 +11300,12 @@ color: #eee;
TriblerGUI.widgets.homepage.h
1
- - PlaylistPage - QWidget -
TriblerGUI.widgets.playlistpage.h
- 1 -
DownloadsDetailsTabWidget QTabWidget
TriblerGUI.widgets.downloadsdetailstabwidget.h
1
- - ManagePlaylistPage - QWidget -
TriblerGUI.widgets.manageplaylistpage.h
- 1 -
CreateTorrentPage QWidget @@ -13183,6 +11382,11 @@ color: #eee;
TriblerGUI.widgets.marketorderspage.h
1
+ + ChannelViewWidget + QWidget +
TriblerGUI.widgets.channelview.h
+
TokenMiningPage QWidget @@ -13193,45 +11397,45 @@ color: #eee; - top_search_bar - returnPressed() + wallets_back_button + clicked() MainWindow - on_top_search_button_click() + on_page_back_clicked() - 308 - 24 + 218 + 75 427 - 317 + 327 - add_torrent_button + transactions_back_button clicked() MainWindow - on_add_torrent_button_click() + on_page_back_clicked() - 826 - 24 + 218 + 75 427 - 317 + 327 - top_menu_button - clicked() + top_search_bar + textChanged(QString) MainWindow - on_top_menu_button_click() + on_search_text_change() - 17 + 308 24 @@ -13241,14 +11445,14 @@ color: #eee; - subscribed_channels_list - itemClicked(QListWidgetItem*) + top_search_bar + returnPressed() MainWindow - on_channel_item_click(QListWidgetItem*) + on_top_search_button_click() - 250 - 65 + 308 + 24 427 @@ -13257,14 +11461,14 @@ color: #eee; - left_menu_button_home + top_menu_button clicked() MainWindow - clicked_menu_button_home() + on_top_menu_button_click() - 100 - 77 + 17 + 24 427 @@ -13273,62 +11477,62 @@ color: #eee; - left_menu_button_my_channel + settings_button clicked() MainWindow - clicked_menu_button_my_channel() + on_settings_button_click() - 100 - 121 + 789 + 24 427 - 317 + 327 - left_menu_button_video_player + orders_back_button clicked() MainWindow - clicked_menu_button_video_player() + on_page_back_clicked() - 100 - 237 + 218 + 75 427 - 317 + 327 - left_menu_button_downloads + market_back_button clicked() MainWindow - clicked_menu_button_downloads() + on_page_back_clicked() - 100 - 197 + 218 + 75 427 - 317 + 327 - left_menu_button_subscriptions + left_menu_button_video_player clicked() MainWindow - clicked_menu_button_subscriptions() + clicked_menu_button_video_player() 100 - 157 + 237 427 @@ -13337,14 +11541,14 @@ color: #eee; - top_search_bar - textChanged(QString) + left_menu_button_subscriptions + clicked() MainWindow - on_search_text_change() + clicked_menu_button_subscriptions() - 308 - 24 + 100 + 157 427 @@ -13353,30 +11557,30 @@ color: #eee; - channel_back_button + left_menu_button_search clicked() MainWindow - on_page_back_clicked() + clicked_menu_button_search() - 218 - 75 + 94 + 111 427 - 317 + 327 - playlist_back_button + left_menu_button_my_channel clicked() MainWindow - on_page_back_clicked() + clicked_menu_button_my_channel() - 221 - 75 + 100 + 121 427 @@ -13385,62 +11589,46 @@ color: #eee; - left_menu_button_discovered + left_menu_button_home clicked() MainWindow - clicked_menu_button_discovered() + clicked_menu_button_home() 100 - 117 - - - 427 - 327 - - - - - discovered_channels_list - itemClicked(QListWidgetItem*) - MainWindow - on_channel_item_click(QListWidgetItem*) - - - 527 - 366 + 77 427 - 327 + 317 - left_menu_button_debug + left_menu_button_downloads clicked() MainWindow - clicked_menu_button_debug() + clicked_menu_button_downloads() - 94 - 327 + 100 + 197 427 - 327 + 317 - settings_button + left_menu_button_discovered clicked() MainWindow - on_settings_button_click() + clicked_menu_button_discovered() - 789 - 24 + 100 + 117 427 @@ -13449,31 +11637,15 @@ color: #eee; - left_menu_button_search + left_menu_button_debug clicked() MainWindow - clicked_menu_button_search() + clicked_menu_button_debug() 94 - 111 - - - 427 327 - - - - market_back_button - clicked() - MainWindow - on_page_back_clicked() - - - 218 - 75 - 427 327 @@ -13481,23 +11653,23 @@ color: #eee; - transactions_back_button + force_shutdown_btn clicked() MainWindow - on_page_back_clicked() + clicked_force_shutdown() - 218 - 75 + 20 + 20 - 427 - 327 + 20 + 20 - wallets_back_button + channel_back_button clicked() MainWindow on_page_back_clicked() @@ -13508,39 +11680,23 @@ color: #eee; 427 - 327 + 317 - orders_back_button + add_torrent_button clicked() MainWindow - on_page_back_clicked() + on_add_torrent_button_click() - 218 - 75 + 826 + 24 427 - 327 - - - - - force_shutdown_btn - clicked() - MainWindow - clicked_force_shutdown() - - - 20 - 20 - - - 20 - 20 + 317 diff --git a/TriblerGUI/qt_resources/playlist_list_item.ui b/TriblerGUI/qt_resources/playlist_list_item.ui deleted file mode 100644 index fd77e4ae63b..00000000000 --- a/TriblerGUI/qt_resources/playlist_list_item.ui +++ /dev/null @@ -1,414 +0,0 @@ - - - Form - - - - 0 - 0 - 585 - 60 - - - - - 0 - 0 - - - - - 0 - 60 - - - - - 16777215 - 60 - - - - PointingHandCursor - - - Form - - - QWidget { -background-color: #666; -} - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 60 - 42 - - - - - 60 - 42 - - - - PO - - - Qt::AlignCenter - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - 0 - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 7 - - - - - - - - 4 - - - - - - 0 - 0 - - - - - 54 - 18 - - - - - 54 - 18 - - - - border: 1px solid #B5B5B5; -border-radius: 9px; -color: #B5B5B5; -font-size: 12px; -background-color: transparent; - - - playlist - - - Qt::AlignCenter - - - - - - - - 0 - 0 - - - - color: #eee; -border: none; -background-color: transparent; -font-size: 15px; - - - TextLabel - - - - - - - - - - - - 0 - 0 - - - - color: #b5b5b5; -border: none; -background-color: transparent; -font-size: 15px; - - - 34 items - - - - - - - - - Qt::Vertical - - - QSizePolicy::Fixed - - - - 20 - 7 - - - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - background: transparent; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 28 - 28 - - - - - 28 - 28 - - - - border-radius: 14px; - - - - - - - ../images/edit_white.png../images/edit_white.png - - - - 12 - 12 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 6 - 20 - - - - - - - - - 28 - 28 - - - - - 28 - 28 - - - - PointingHandCursor - - - border-radius: 14px; - - - - - - - ../images/delete.png../images/delete.png - - - - 12 - 12 - - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 14 - 20 - - - - - - - - - - - - CircleButton - QToolButton -
TriblerGUI.widgets.circlebutton.h
-
- - ThumbnailWidget - QLabel -
TriblerGUI.widgets.thumbnailwidget.h
-
-
- - -
diff --git a/TriblerGUI/qt_resources/torrent_channel_list_container.ui b/TriblerGUI/qt_resources/torrent_channel_list_container.ui index bd46b656277..4e12dc446bf 100644 --- a/TriblerGUI/qt_resources/torrent_channel_list_container.ui +++ b/TriblerGUI/qt_resources/torrent_channel_list_container.ui @@ -68,7 +68,7 @@ QSplitter::handle { background-color: #555; } - Qt::Vertical + Qt::Horizontal @@ -436,6 +436,11 @@ background-color: transparent; QListWidget
TriblerGUI.widgets.lazyloadlist.h
+ + LazyTableView + QTableView +
TriblerGUI.widgets.lazytableview.h
+
TorrentDetailsTabWidget QTabWidget diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index 19fb0d1e16a..080c41e867c 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -8,9 +8,10 @@ import time import traceback import urlparse +from PyQt5 import uic from urllib import pathname2url, unquote -from PyQt5 import uic +import six from PyQt5.QtCore import QCoreApplication, QObject, QPoint, QSettings, QStringListModel, QTimer, QUrl, Qt, \ pyqtSignal, pyqtSlot from PyQt5.QtCore import QDir @@ -21,16 +22,13 @@ QStyledItemDelegate, QSystemTrayIcon, QTreeWidget from PyQt5.QtWidgets import QShortcut -import six - from Tribler.Core.Modules.process_checker import ProcessChecker - from TriblerGUI.core_manager import CoreManager from TriblerGUI.debug_window import DebugWindow -from TriblerGUI.defs import BUTTON_TYPE_CONFIRM, BUTTON_TYPE_NORMAL, DEFAULT_API_PORT, PAGE_CHANNEL_DETAILS, \ - PAGE_DISCOVERED, PAGE_DISCOVERING, PAGE_DOWNLOADS, PAGE_EDIT_CHANNEL, PAGE_HOME, PAGE_LOADING, \ - PAGE_PLAYLIST_DETAILS, PAGE_SEARCH_RESULTS, PAGE_SETTINGS, PAGE_SUBSCRIBED_CHANNELS, PAGE_TRUST, \ - PAGE_VIDEO_PLAYER, SHUTDOWN_WAITING_PERIOD +from TriblerGUI.defs import PAGE_SEARCH_RESULTS, \ + PAGE_HOME, PAGE_EDIT_CHANNEL, PAGE_VIDEO_PLAYER, PAGE_DOWNLOADS, PAGE_SETTINGS, PAGE_SUBSCRIBED_CHANNELS, \ + PAGE_CHANNEL_DETAILS, BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, PAGE_LOADING, \ + PAGE_DISCOVERING, PAGE_DISCOVERED, PAGE_TRUST, SHUTDOWN_WAITING_PERIOD, DEFAULT_API_PORT from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog from TriblerGUI.dialogs.feedbackdialog import FeedbackDialog from TriblerGUI.dialogs.startdownloaddialog import StartDownloadDialog @@ -39,9 +37,6 @@ from TriblerGUI.utilities import get_gui_setting, get_image_path, get_ui_file_path, is_dir_writable, quote_plus_unicode # Pre-load form UI classes -fc_channel_torrent_list_item, _ = uic.loadUiType(get_ui_file_path('channel_torrent_list_item.ui')) -fc_channel_list_item, _ = uic.loadUiType(get_ui_file_path('channel_list_item.ui')) -fc_playlist_list_item, _ = uic.loadUiType(get_ui_file_path('playlist_list_item.ui')) fc_home_recommended_item, _ = uic.loadUiType(get_ui_file_path('home_recommended_item.ui')) fc_loading_list_item, _ = uic.loadUiType(get_ui_file_path('loading_list_item.ui')) @@ -134,19 +129,6 @@ def __init__(self, core_args=None, core_env=None, api_port=None): TriblerRequestManager.window = self self.tribler_status_bar.hide() - # Load dynamic widgets - uic.loadUi(get_ui_file_path('torrent_channel_list_container.ui'), self.channel_page_container) - self.channel_torrents_list = self.channel_page_container.items_list - self.channel_torrents_detail_widget = self.channel_page_container.details_tab_widget - self.channel_torrents_detail_widget.initialize_details_widget() - self.channel_torrents_list.itemSelectionChanged.connect(self.channel_page.clicked_item) - - uic.loadUi(get_ui_file_path('torrent_channel_list_container.ui'), self.search_page_container) - self.search_results_list = self.search_page_container.items_list - self.search_torrents_detail_widget = self.search_page_container.details_tab_widget - self.search_torrents_detail_widget.initialize_details_widget() - self.search_results_list.itemClicked.connect(self.on_channel_item_click) - self.search_results_list.itemSelectionChanged.connect(self.search_results_page.clicked_item) self.token_balance_widget.mouseReleaseEvent = self.on_token_balance_click def on_state_update(new_state): @@ -206,7 +188,6 @@ def on_state_update(new_state): else: self.tray_icon = None - self.hide_left_menu_playlist() self.left_menu_button_debug.setHidden(True) self.top_menu_button.setHidden(True) self.left_menu.setHidden(True) @@ -248,10 +229,6 @@ def on_state_update(new_state): # Start Tribler self.core_manager.start(core_args=core_args, core_env=core_env) - self.core_manager.events_manager.received_search_result_channel.connect( - self.search_results_page.received_search_result_channel) - self.core_manager.events_manager.received_search_result_torrent.connect( - self.search_results_page.received_search_result_torrent) self.core_manager.events_manager.torrent_finished.connect(self.on_torrent_finished) self.core_manager.events_manager.new_version_available.connect(self.on_new_version_available) self.core_manager.events_manager.tribler_started.connect(self.on_tribler_started) @@ -520,8 +497,6 @@ def on_top_search_button_click(self): self.has_search_results = True self.clicked_menu_button_search() self.search_results_page.perform_search(current_search_query) - self.search_request_mgr = TriblerRequestManager() - self.search_request_mgr.perform_request("search?q=%s" % current_search_query, None) self.last_search_query = current_search_query self.last_search_time = current_ts @@ -530,7 +505,6 @@ def on_settings_button_click(self): self.stackedWidget.setCurrentIndex(PAGE_SETTINGS) self.settings_page.load_settings() self.navigation_stack = [] - self.hide_left_menu_playlist() def on_token_balance_click(self, _): self.raise_window() @@ -539,7 +513,6 @@ def on_token_balance_click(self, _): self.load_token_balance() self.trust_page.load_blocks() self.navigation_stack = [] - self.hide_left_menu_playlist() def load_token_balance(self): self.request_mgr = TriblerRequestManager() @@ -565,7 +538,7 @@ def received_trustchain_statistics(self, statistics): self.trust_page.load_blocks() def set_token_balance(self, balance): - if abs(balance) > 1024 ** 4: # Balance is over a TB + if abs(balance) > 1024 ** 4: # Balance is over a TB balance /= 1024.0 ** 4 self.token_balance_label.setText("%.1f TB" % balance) elif abs(balance) > 1024 ** 3: # Balance is over a GB @@ -703,9 +676,9 @@ def on_confirm_add_directory_dialog(self, action): escaped_uri = u"file:%s" % pathname2url(torrent_file.encode('utf-8')) self.perform_start_download_request(escaped_uri, self.window().tribler_settings['download_defaults'][ - 'anonymity_enabled'], + 'anonymity_enabled'], self.window().tribler_settings['download_defaults'][ - 'safeseeding_enabled'], + 'safeseeding_enabled'], self.tribler_settings['download_defaults']['saveas'], [], 0) if self.dialog: @@ -771,41 +744,35 @@ def clicked_menu_button_home(self): self.deselect_all_menu_buttons(self.left_menu_button_home) self.stackedWidget.setCurrentIndex(PAGE_HOME) self.navigation_stack = [] - self.hide_left_menu_playlist() def clicked_menu_button_search(self): self.deselect_all_menu_buttons(self.left_menu_button_search) self.stackedWidget.setCurrentIndex(PAGE_SEARCH_RESULTS) self.navigation_stack = [] - self.hide_left_menu_playlist() def clicked_menu_button_discovered(self): self.deselect_all_menu_buttons(self.left_menu_button_discovered) self.stackedWidget.setCurrentIndex(PAGE_DISCOVERED) self.discovered_page.load_discovered_channels() self.navigation_stack = [] - self.hide_left_menu_playlist() def clicked_menu_button_my_channel(self): self.deselect_all_menu_buttons(self.left_menu_button_my_channel) self.stackedWidget.setCurrentIndex(PAGE_EDIT_CHANNEL) self.edit_channel_page.load_my_channel_overview() self.navigation_stack = [] - self.hide_left_menu_playlist() def clicked_menu_button_video_player(self): self.deselect_all_menu_buttons(self.left_menu_button_video_player) self.stackedWidget.setCurrentIndex(PAGE_VIDEO_PLAYER) self.navigation_stack = [] - self.show_left_menu_playlist() def clicked_menu_button_downloads(self): + self.deselect_all_menu_buttons(self.left_menu_button_downloads) self.raise_window() self.left_menu_button_downloads.setChecked(True) - self.deselect_all_menu_buttons(self.left_menu_button_downloads) self.stackedWidget.setCurrentIndex(PAGE_DOWNLOADS) self.navigation_stack = [] - self.hide_left_menu_playlist() def clicked_menu_button_debug(self): if not self.debug_window: @@ -817,46 +784,16 @@ def clicked_menu_button_subscriptions(self): self.subscribed_channels_page.load_subscribed_channels() self.stackedWidget.setCurrentIndex(PAGE_SUBSCRIBED_CHANNELS) self.navigation_stack = [] - self.hide_left_menu_playlist() - - def hide_left_menu_playlist(self): - self.left_menu_seperator.setHidden(True) - self.left_menu_playlist_label.setHidden(True) - self.left_menu_playlist.setHidden(True) - - def show_left_menu_playlist(self): - self.left_menu_seperator.setHidden(False) - self.left_menu_playlist_label.setHidden(False) - self.left_menu_playlist.setHidden(False) - - def on_channel_item_click(self, channel_list_item): - list_widget = channel_list_item.listWidget() - from TriblerGUI.widgets.channel_list_item import ChannelListItem - if isinstance(list_widget.itemWidget(channel_list_item), ChannelListItem): - channel_info = channel_list_item.data(Qt.UserRole) - self.channel_page.initialize_with_channel(channel_info) - self.navigation_stack.append(self.stackedWidget.currentIndex()) - self.stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) - - def on_playlist_item_click(self, playlist_list_item): - list_widget = playlist_list_item.listWidget() - from TriblerGUI.widgets.playlist_list_item import PlaylistListItem - if isinstance(list_widget.itemWidget(playlist_list_item), PlaylistListItem): - playlist_info = playlist_list_item.data(Qt.UserRole) - self.playlist_page.initialize_with_playlist(playlist_info) - self.navigation_stack.append(self.stackedWidget.currentIndex()) - self.stackedWidget.setCurrentIndex(PAGE_PLAYLIST_DETAILS) + + def on_channel_clicked(self, public_key): + self.channel_page.initialize_with_channel(public_key) + self.navigation_stack.append(self.stackedWidget.currentIndex()) + self.stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) def on_page_back_clicked(self): try: prev_page = self.navigation_stack.pop() self.stackedWidget.setCurrentIndex(prev_page) - if prev_page == PAGE_SEARCH_RESULTS: - self.stackedWidget.widget(prev_page).load_search_results_in_list() - if prev_page == PAGE_SUBSCRIBED_CHANNELS: - self.stackedWidget.widget(prev_page).load_subscribed_channels() - if prev_page == PAGE_DISCOVERED: - self.stackedWidget.widget(prev_page).load_discovered_channels() except IndexError: logging.exception("Unknown page found in stack") diff --git a/TriblerGUI/widgets/channel_list_item.py b/TriblerGUI/widgets/channel_list_item.py deleted file mode 100644 index a5f05f1beda..00000000000 --- a/TriblerGUI/widgets/channel_list_item.py +++ /dev/null @@ -1,21 +0,0 @@ -from PyQt5.QtWidgets import QWidget -from TriblerGUI.tribler_window import fc_channel_list_item - - -class ChannelListItem(QWidget, fc_channel_list_item): - """ - This class is responsible for managing the item in the list of channels. - The list item supports a fade-in effect, which can be enabled with the should_fade parameter in the constructor. - """ - - def __init__(self, parent, channel): - QWidget.__init__(self, parent) - fc_channel_list_item.__init__(self) - - self.setupUi(self) - - self.channel_info = channel - self.channel_name.setText(channel["name"]) - self.channel_description_label.setText("%d items" % channel["torrents"]) - - self.subscriptions_widget.initialize_with_channel(channel) diff --git a/TriblerGUI/widgets/channel_torrent_list_item.py b/TriblerGUI/widgets/channel_torrent_list_item.py deleted file mode 100644 index 9162540532d..00000000000 --- a/TriblerGUI/widgets/channel_torrent_list_item.py +++ /dev/null @@ -1,198 +0,0 @@ -from urllib import quote_plus - -import logging -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget -from TriblerGUI.defs import STATUS_GOOD, STATUS_DEAD, COMMITTED, TODELETE, UNCOMMITTED -from TriblerGUI.defs import STATUS_UNKNOWN - -from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.tribler_window import fc_channel_torrent_list_item -from TriblerGUI.utilities import format_size, get_image_path, get_gui_setting - - -class ChannelTorrentListItem(QWidget, fc_channel_torrent_list_item): - """ - This class is responsible for managing the item in the torrents list of a channel. - """ - - def __init__(self, parent, torrent, show_controls=False, on_remove_clicked=None): - QWidget.__init__(self, parent) - fc_channel_torrent_list_item.__init__(self) - - self.torrent_info = torrent - self._logger = logging.getLogger('TriblerGUI') - - self.setupUi(self) - self.show_controls = show_controls - self.remove_control_button_container.setHidden(True) - self.control_buttons_container.setHidden(True) - self.is_health_checking = False - self.has_health = False - self.health_request_mgr = None - self.request_mgr = None - self.download_uri = None - self.dialog = None - - self.channel_torrent_name.setText(torrent["name"]) - if torrent["size"] is None: - self.channel_torrent_description.setText("Size: -") - else: - self.channel_torrent_description.setText("Size: %s" % format_size(float(torrent["size"]))) - - if torrent["category"]: - self.channel_torrent_category.setText(torrent["category"].lower()) - else: - self.channel_torrent_category.setText("unknown") - self.thumbnail_widget.initialize(torrent["name"], 24) - - if torrent["last_tracker_check"] > 0: - self.has_health = True - self.update_health(int(torrent["num_seeders"]), int(torrent["num_leechers"])) - - if "commit_status" in torrent: - self.update_commit_status(torrent["commit_status"]) - else: - self.commit_state_label.setHidden(True) - - self.torrent_play_button.clicked.connect(self.on_play_button_clicked) - self.torrent_download_button.clicked.connect(self.on_download_clicked) - - if not self.window().vlc_available: - self.torrent_play_button.setHidden(True) - - if on_remove_clicked is not None: - self.remove_torrent_button.clicked.connect(lambda: on_remove_clicked(self)) - - def on_download_clicked(self): - self.download_uri = (u"magnet:?xt=urn:btih:%s&dn=%s" % - (self.torrent_info["infohash"], self.torrent_info['name'])).encode('utf-8') - self.window().start_download_from_uri(self.download_uri) - - def on_play_button_clicked(self): - self.download_uri = (u"magnet:?xt=urn:btih:%s&dn=%s" % - (self.torrent_info["infohash"], self.torrent_info['name'])).encode('utf-8') - - self.window().perform_start_download_request(self.download_uri, - self.window().tribler_settings['download_defaults'][ - 'anonymity_enabled'], - self.window().tribler_settings['download_defaults'][ - 'safeseeding_enabled'], - self.window().tribler_settings['download_defaults']['saveas'], - [], 0, callback=self.on_play_request_done) - - def on_play_request_done(self, result): - if not self: - return - self.window().left_menu_button_video_player.click() - self.window().video_player_page.play_media_item(self.torrent_info["infohash"], -1) - - def show_buttons(self): - if not self.show_controls: - self.remove_control_button_container.setHidden(True) - self.control_buttons_container.setHidden(False) - self.torrent_play_button.setIcon(QIcon(get_image_path('play.png'))) - self.torrent_download_button.setIcon(QIcon(get_image_path('downloads.png'))) - else: - self.control_buttons_container.setHidden(True) - self.remove_control_button_container.setHidden(False) - self.remove_torrent_button.setIcon(QIcon(get_image_path('delete.png'))) - - def hide_buttons(self): - self.remove_control_button_container.setHidden(True) - self.control_buttons_container.setHidden(True) - - def enterEvent(self, _): - self.show_buttons() - - def leaveEvent(self, _): - self.hide_buttons() - - def on_cancel_health_check(self): - """ - The request for torrent health could not be queued. - Go back to the intial state. - """ - try: - self.health_text.setText("unknown health") - self.set_health_indicator(STATUS_UNKNOWN) - self.is_health_checking = False - self.has_health = False - except RuntimeError: - self._logger.error("The underlying GUI widget has already been removed.") - - def check_health(self): - """ - Perform a request to check the health of the torrent that is represented by this widget. - Don't do this if we are already checking the health or if we have the health info. - """ - if self.is_health_checking or self.has_health: # Don't check health again - return - - self.health_text.setText("checking health...") - self.set_health_indicator(STATUS_UNKNOWN) - self.is_health_checking = True - self.health_request_mgr = TriblerRequestManager() - self.health_request_mgr.perform_request("torrents/%s/health?timeout=15" % self.torrent_info["infohash"], - self.on_health_response, capture_errors=False, priority="LOW", - on_cancel=self.on_cancel_health_check) - - def on_health_response(self, response): - """ - When we receive a health response, update the health status. - """ - if not self: # The channel list item might have been deleted already (i.e. by doing another search). - return - - self.has_health = True - total_seeders = 0 - total_leechers = 0 - - if not response or 'error' in response: - self.update_health(0, 0) # Just set the health to 0 seeders, 0 leechers - return - - for _, status in response['health'].iteritems(): - if 'error' in status: - continue # Timeout or invalid status - - total_seeders += int(status['seeders']) - total_leechers += int(status['leechers']) - - self.is_health_checking = False - self.update_health(total_seeders, total_leechers) - - def update_health(self, seeders, leechers): - try: - if seeders > 0: - self.health_text.setText("good health (S%d L%d)" % (seeders, leechers)) - self.set_health_indicator(STATUS_GOOD) - elif leechers > 0: - self.health_text.setText("unknown health (found peers)") - self.set_health_indicator(STATUS_UNKNOWN) - else: - self.health_text.setText("no peers found") - self.set_health_indicator(STATUS_DEAD) - except RuntimeError: - self._logger.error("The underlying GUI widget has already been removed.") - - def update_commit_status(self, status): - if status == COMMITTED: - self.commit_state_label.setText("Committed") - if status == TODELETE: - self.commit_state_label.setText("To delete") - self.remove_torrent_button.setHidden(True) - if status == UNCOMMITTED: - self.commit_state_label.setText("Uncommitted") - - def set_health_indicator(self, status): - color = "orange" - if status == STATUS_GOOD: - color = "green" - elif status == STATUS_UNKNOWN: - color = "orange" - elif status == STATUS_DEAD: - color = "red" - - self.health_indicator.setStyleSheet("background-color: %s; border-radius: %dpx" - % (color, self.health_indicator.height() / 2)) diff --git a/TriblerGUI/widgets/channelpage.py b/TriblerGUI/widgets/channelpage.py index bf1928a4f7d..be7f0569ddb 100644 --- a/TriblerGUI/widgets/channelpage.py +++ b/TriblerGUI/widgets/channelpage.py @@ -1,113 +1,37 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.channel_torrent_list_item import ChannelTorrentListItem -from TriblerGUI.widgets.loading_list_item import LoadingListItem -from TriblerGUI.widgets.playlist_list_item import PlaylistListItem -from TriblerGUI.widgets.text_list_item import TextListItem -from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.utilities import get_image_path class ChannelPage(QWidget): """ - The ChannelPage is the page with an overview of each channel and displays the list of torrents/playlist available. + The ChannelPage displays a list of a channel's contents. """ def __init__(self): QWidget.__init__(self) - - self.playlists = [] - self.torrents = [] - self.loaded_channels = False - self.loaded_playlists = False self.channel_info = None - self.get_torents_in_channel_manager = None - self.get_playlists_in_channel_manager = None - def initialize_with_channel(self, channel_info): - self.playlists = [] - self.torrents = [] - self.loaded_channels = False - self.loaded_playlists = False - - self.get_torents_in_channel_manager = None - self.get_playlists_in_channel_manager = None - self.channel_info = channel_info + self.window().channel_page_container.initialize_model(channel_id=channel_info['public_key']) - self.window().channel_torrents_list.set_data_items([(LoadingListItem, None)]) - self.window().channel_torrents_detail_widget.hide() + container = self.window().channel_page_container + container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) + container.torrents_table.setColumnHidden(container.model.column_position[u'subscribed'], True) + container.buttons_container.setHidden(True) - self.window().channel_preview_label.setHidden(channel_info['subscribed']) - self.window().channel_back_button.setIcon(QIcon(get_image_path('page_back.png'))) - - self.get_torents_in_channel_manager = TriblerRequestManager() - self.get_torents_in_channel_manager.perform_request("channels/discovered/%s/torrents" % - channel_info['dispersy_cid'], - self.received_torrents_in_channel) - - if len(channel_info['dispersy_cid']) == 148: # Check-hack for Channel2.0 style address - self.loaded_playlists = True + if len(channel_info['public_key']) == 40: + container.top_bar_container.setHidden(True) else: - self.get_playlists_in_channel_manager = TriblerRequestManager() - self.get_playlists_in_channel_manager.perform_request("channels/discovered/%s/playlists" % - channel_info['dispersy_cid'], - self.received_playlists_in_channel) + container.top_bar_container.setHidden(False) + container.dirty_channel_bar.setHidden(True) + + self.window().channel_preview_label.setHidden(int(channel_info['subscribed']) == 1) + self.window().channel_back_button.setIcon(QIcon(get_image_path('page_back.png'))) # initialize the page about a channel self.window().channel_name_label.setText(channel_info['name']) self.window().num_subs_label.setText(str(channel_info['votes'])) self.window().subscription_widget.initialize_with_channel(channel_info) - - def clicked_item(self): - if len(self.window().channel_torrents_list.selectedItems()) != 1: - self.window().channel_torrents_detail_widget.hide() - else: - item = self.window().channel_torrents_list.selectedItems()[0] - list_widget = item.listWidget() - list_item = list_widget.itemWidget(item) - if isinstance(list_item, ChannelTorrentListItem): - self.window().channel_torrents_detail_widget.update_with_torrent(list_item.torrent_info) - self.window().channel_torrents_detail_widget.show() - else: - self.window().channel_torrents_detail_widget.hide() - - def update_result_list(self): - if self.loaded_channels and self.loaded_playlists: - self.window().channel_torrents_list.set_data_items(self.playlists + self.torrents) - - def received_torrents_in_channel(self, results): - if not results: - return - def sort_key(torrent): - """ Scoring algorithm for sorting the torrent to show liveness. The score is basically the sum of number - of seeders and leechers. If swarm info is unknown, we give unknown seeder and leecher as 0.5 & 0.4 so - that the sum is less than 1 and higher than zero. This means unknown torrents will have higher score - than dead torrent with no seeders and leechers and lower score than any barely alive torrent with a - single seeder or leecher. - """ - seeder_score = torrent['num_seeders'] if torrent['num_seeders'] or torrent['last_tracker_check'] > 0\ - else 0.5 - leecher_score = torrent['num_leechers'] if torrent['num_leechers'] or torrent['last_tracker_check'] > 0\ - else 0.5 - return seeder_score + .5 * leecher_score - - for result in sorted(results['torrents'], key=sort_key, reverse=True): - self.torrents.append((ChannelTorrentListItem, result)) - - if not self.channel_info['subscribed']: - self.torrents.append((TextListItem, "You're looking at a preview of this channel.\n" - "Subscribe to this channel to see the full content.")) - - self.loaded_channels = True - self.update_result_list() - - def received_playlists_in_channel(self, results): - if not results: - return - for result in results['playlists']: - self.playlists.append((PlaylistListItem, result)) - self.loaded_playlists = True - self.update_result_list() diff --git a/TriblerGUI/widgets/channelview.py b/TriblerGUI/widgets/channelview.py new file mode 100644 index 00000000000..3b2512f261d --- /dev/null +++ b/TriblerGUI/widgets/channelview.py @@ -0,0 +1,446 @@ +from __future__ import absolute_import + +import base64 +import time +import urllib +from PyQt5 import uic, QtCore + +from PyQt5.QtCore import Qt, QDir, pyqtSignal +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QWidget, QAction, QFileDialog, QAbstractItemView + +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, COMMITTED, NEW +from Tribler.Core.Modules.MetadataStore.serialization import float2time +from Tribler.Core.Modules.restapi.util import HEALTH_MOOT, HEALTH_GOOD, HEALTH_DEAD, HEALTH_CHECKING, HEALTH_ERROR +from TriblerGUI.defs import BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, \ + PAGE_EDIT_CHANNEL_CREATE_TORRENT +from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog +from TriblerGUI.tribler_action_menu import TriblerActionMenu +from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.utilities import get_ui_file_path, format_size +from TriblerGUI.widgets.lazytableview import RemoteTableModel, ACTION_BUTTONS +from TriblerGUI.widgets.torrentdetailstabwidget import TorrentDetailsTabWidget + +commit_status_labels = { + COMMITTED: "Committed", + TODELETE: "To delete", + NEW: "Uncommitted" +} + + +class ChannelContentsModel(RemoteTableModel): + columns = [u'category', u'name', u'size', u'date', u'health', u'subscribed', u'commit_status', ACTION_BUTTONS] + column_headers = [u'Category', u'Title', u'Size', u'Date', u'Health', u'S', u'Status', u''] + column_position = {name: i for i, name in enumerate(columns)} + + column_width = {u'subscribed': 35, + u'date': 80, + u'size': 80, + u'commit_status': 35, + u'name': 200, + u'category': 100, + u'health': 80, + ACTION_BUTTONS: 65} + num_columns = len(columns) + column_flags = { + u'subscribed': Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable, + u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable, + u'size': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'date': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'commit_status': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'health': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable + } + + column_display_filters = { + u'size': lambda data: format_size(float(data)), + u'date': lambda data: str((float2time(float(data)).strftime("%Y-%m-%d"))) + } + + def __init__(self, parent=None, channel_id=None, search_query=None, search_type=None, subscribed=None, + commit_widget=None): + self.channel_id = channel_id + self.commit_widget = commit_widget + self.txt_filter = search_query or '' + self.search_type = search_type + self.data_items = [] + self.subscribed = subscribed + + # This dict keeps the mapping of infohashes in data_items to indexes + # It is used by Health Checker to track the health status updates across model refreshes + self.infohashes = {} + self.last_health_check_ts = {} + + super(ChannelContentsModel, self).__init__(parent) + + def headerData(self, num, orientation, role=None): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self.column_headers[num] + + def _get_remote_data(self, start, end, sort_column=None, sort_order=None): + sort_by = (("-" if sort_order else "") + self.columns[sort_column]) if sort_column else None + request_mgr = TriblerRequestManager() + request_mgr.perform_request( + "search?first=%i&last=%i" % (start, end) + + (('&sort_by=%s' % sort_by) if sort_by else '') + + (('&channel=%s' % self.channel_id) if self.channel_id else '') + + (('&type=%s' % self.search_type) if self.search_type else '') + + (('&txt=%s' % self.txt_filter) if self.txt_filter else '') + + (('&subscribed=%i' % self.subscribed) if self.subscribed else ''), + self._new_items_received_callback) + + def refresh(self): + # Health Checker related + # Infohash to data_items mapping should be cleaned each time we refresh the model + self.infohashes.clear() + super(ChannelContentsModel, self).refresh() + + def _new_items_received_callback(self, response): + # TODO: make commit_widget a separate view into this model instead of using this ugly hook. + # Or just use a QT signal for changing its visibility state. + if not response: + return + if self.commit_widget: + self.commit_widget.setHidden(not ("chant_dirty" in response and response["chant_dirty"])) + if 'torrents' in response: + # Health checker related + # Update infohashes -> data_items mapping + for n, item in enumerate(response['torrents']): + self.infohashes[item[u'infohash']] = len(self.data_items) + n + self._on_new_items_received(response['torrents']) + + def data(self, index, role): + if role == Qt.TextAlignmentRole: + if index.column() == self.column_position[u'date']: + return Qt.AlignHCenter | Qt.AlignVCenter + if index.column() == self.column_position[u'size']: + return Qt.AlignHCenter | Qt.AlignVCenter + + i = index.row() + j = index.column() + if role == Qt.DisplayRole: + column = self.columns[j] + data = self.data_items[i][column] if column in self.data_items[i] else u'UNDEFINED' + return self.column_display_filters.get(column, str(data))(data) \ + if column in self.column_display_filters else data + + def rowCount(self, parent=QtCore.QModelIndex()): + return len(self.data_items) + + def columnCount(self, parent=QtCore.QModelIndex()): + return self.num_columns + + def _set_remote_data(self): + pass + + def flags(self, index): + return self.column_flags[self.columns[index.column()]] + + def add_torrent_to_channel(self, filename): + with open(filename, "rb") as torrent_file: + torrent_content = urllib.quote_plus(base64.b64encode(torrent_file.read())) + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents" % + self.channel_id, + self.on_torrent_to_channel_added, method='PUT', + data='torrent=%s' % torrent_content) + + def add_dir_to_channel(self, dirname, recursive=False): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents" % + self.channel_id, + self.on_torrent_to_channel_added, method='PUT', + data=((u'torrents_dir=%s' % dirname) + + (u'&recursive=1' if recursive else u'')).encode('utf-8')) + + def add_torrent_url_to_channel(self, url): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents/%s" % + (self.channel_id, url), + self.on_torrent_to_channel_added, method='PUT') + + def on_torrent_to_channel_added(self, result): + if not result: + return + if 'added' in result: + self.refresh() + + def update_torrent_health(self, infohash, seeders, leechers, health): + if infohash in self.infohashes: + row = self.infohashes[infohash] + self.data_items[row][u'num_seeders'] = seeders + self.data_items[row][u'num_leechers'] = leechers + self.data_items[row][u'health'] = health + index = self.index(row, self.column_position[u'health']) + self.dataChanged.emit(index, index, []) + + def check_torrent_health(self, index): + timeout = 15 + infohash = self.data_items[index.row()][u'infohash'] + + # TODO: move timeout check to the endpoint + if infohash in self.last_health_check_ts and \ + (time.time() - self.last_health_check_ts[infohash] < timeout): + return + self.last_health_check_ts[infohash] = time.time() + + def on_cancel_health_check(): + pass + + def on_health_response(response): + + self.last_health_check_ts[infohash] = time.time() + total_seeders = 0 + total_leechers = 0 + + if not response or 'error' in response: + self.update_torrent_health(infohash, 0, 0, HEALTH_ERROR) # Just set the health to 0 seeders, 0 leechers + return + + for _, status in response['health'].iteritems(): + if 'error' in status: + continue # Timeout or invalid status + total_seeders += int(status['seeders']) + total_leechers += int(status['leechers']) + + if total_seeders > 0: + health = HEALTH_GOOD + elif total_leechers > 0: + health = HEALTH_MOOT + else: + health = HEALTH_DEAD + + self.update_torrent_health(infohash, total_seeders, total_leechers, health) + + self.data_items[index.row()][u'health'] = HEALTH_CHECKING + index_upd = self.index(index.row(), self.column_position[u'health']) + self.dataChanged.emit(index_upd, index_upd, []) + health_request_mgr = TriblerRequestManager() + health_request_mgr.perform_request("torrents/%s/health?timeout=%s&refresh=%d" % + (infohash, timeout, 1), + on_health_response, capture_errors=False, priority="LOW", + on_cancel=on_cancel_health_check) + + +class ChannelViewWidget(QWidget): + channel_entry_clicked = pyqtSignal(dict) + + def __init__(self, parent=None): + self.remove_torrent_requests = [] + self.model = None + self.dialog = None + self.chosen_dir = None + self.details_tab_widget = None + QWidget.__init__(self, parent=parent) + uic.loadUi(get_ui_file_path('channel_view.ui'), self) + + # Connect torrent addition/removal buttons + self.remove_selected_button.clicked.connect(self.on_torrents_remove_selected_clicked) + self.remove_all_button.clicked.connect(self.on_torrents_remove_all_clicked) + self.add_button.clicked.connect(self.on_torrents_add_clicked) + + # "Commit changes" widget is hidden by default and only shown when necessary + self.dirty_channel_bar.setHidden(True) + self.edit_channel_commit_button.clicked.connect(self.clicked_edit_channel_commit_button) + + # Connect "filter" edit box + self.search_edit.editingFinished.connect(self.search_edit_finished) + + self.details_tab_widget = self.findChild(TorrentDetailsTabWidget, "details_tab_widget") + self.details_tab_widget.initialize_details_widget() + self.details_tab_widget.health_check_clicked.connect(self.on_details_tab_widget_health_check_clicked) + + self.torrents_table.clicked.connect(self.on_table_item_clicked) + self.torrents_table.verticalHeader().hide() + self.torrents_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.torrents_table.setSelectionMode(QAbstractItemView.ExtendedSelection) + + def on_details_tab_widget_health_check_clicked(self, torrent_info): + infohash = torrent_info[u'infohash'] + if infohash in self.model.infohashes: + self.model.check_torrent_health(self.model.index(self.model.infohashes[infohash], 0)) + + def on_table_item_clicked(self, item): + if item.column() == self.model.column_position[ACTION_BUTTONS] or \ + item.column() == self.model.column_position[u'subscribed'] or \ + item.column() == self.model.column_position[u'commit_status']: + return + table_entry = self.model.data_items[item.row()] + if table_entry['type'] == u'torrent': + self.details_tab_widget.update_with_torrent(table_entry) + self.details_tab_widget.setHidden(False) + self.model.check_torrent_health(item) + elif table_entry['type'] == u'channel': + self.channel_entry_clicked.emit(table_entry) + + def set_model(self, model): + self.model = model + self.torrents_table.setModel(self.model) + self.reset_column_width() + self.details_tab_widget.setHidden(True) + + # TODO: instead, refactor Details Widget into a View + # This ensures that when the Health Checker updates the state of some rows in the model, + # the Details Widget will be notified about these changes + self.model.dataChanged.connect(self.details_tab_widget.update_from_model) + + def reset_column_width(self): + for col in self.model.column_width: + self.torrents_table.setColumnWidth(self.model.column_position[col], self.model.column_width[col]) + + def initialize_model(self, channel_id=None, search_query=None, search_type=None, subscribed=None): + self.model = ChannelContentsModel(parent=None, + channel_id=channel_id, + search_query=search_query, + search_type=search_type, + subscribed=subscribed, + commit_widget=self.dirty_channel_bar) + self.set_model(self.model) + + # Search related-methods + def search_edit_finished(self): + if self.model.txt_filter != self.search_edit.text(): + self.model.txt_filter = self.search_edit.text() + self.model.refresh() + + # Torrent removal-related methods + def on_torrents_remove_selected_clicked(self): + selected_items = self.torrents_table.selectedIndexes() + num_selected = len(selected_items) + if num_selected == 0: + return + + selected_infohashes = [self.model.data_items[row][u'infohash'] for row in + set([index.row() for index in selected_items])] + self.dialog = ConfirmationDialog(self, "Remove %s selected torrents" % num_selected, + "Are you sure that you want to remove %s selected torrents " + "from your channel?" % len(selected_infohashes), + [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) + self.dialog.button_clicked.connect(lambda action: + self.on_torrents_remove_selected_action(action, selected_infohashes)) + self.dialog.show() + + def on_torrent_removed(self, json_result): + if not json_result: + return + if 'removed' in json_result and json_result['removed']: + self.model.refresh() + + def on_torrents_remove_selected_action(self, action, items): + if action == 0: + if isinstance(items, list): + infohash = ",".join(items) + else: + infohash = items + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents/%s" % + (self.model.channel_id, infohash), + self.on_torrent_removed, method='DELETE') + if self.dialog: + self.dialog.close_dialog() + self.dialog = None + + def on_torrents_remove_all_action(self, action): + if action == 0: + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents/*" % self.model.channel_id, + None, method='DELETE') + self.model.refresh() + + self.dialog.close_dialog() + self.dialog = None + + def on_torrents_remove_all_clicked(self): + self.dialog = ConfirmationDialog(self.window(), "Remove all torrents", + "Are you sure that you want to remove all torrents from your channel? " + "You cannot undo this action.", + [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) + self.dialog.button_clicked.connect(self.on_torrents_remove_all_action) + self.dialog.show() + + # Torrent addition-related methods + def on_add_torrents_browse_dir(self): + chosen_dir = QFileDialog.getExistingDirectory(self, + "Please select the directory containing the .torrent files", + QDir.homePath(), + QFileDialog.ShowDirsOnly) + if not chosen_dir: + return + + self.chosen_dir = chosen_dir + self.dialog = ConfirmationDialog(self, "Add torrents from directory", + "Add all torrent files from the following directory to your Tribler channel:\n\n%s" % + chosen_dir, + [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], + checkbox_text="Include subdirectories (recursive mode)") + self.dialog.button_clicked.connect(self.on_confirm_add_directory_dialog) + self.dialog.show() + + def on_confirm_add_directory_dialog(self, action): + if action == 0: + self.model.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) + + if self.dialog: + self.dialog.close_dialog() + self.dialog = None + self.chosen_dir = None + + def on_torrents_add_clicked(self): + menu = TriblerActionMenu(self) + + browse_files_action = QAction('Import torrent from file', self) + browse_dir_action = QAction('Import torrent(s) from dir', self) + add_url_action = QAction('Add URL', self) + create_torrent_action = QAction('Create torrent from file(s)', self) + + browse_files_action.triggered.connect(self.on_add_torrent_browse_file) + browse_dir_action.triggered.connect(self.on_add_torrents_browse_dir) + add_url_action.triggered.connect(self.on_add_torrent_from_url) + create_torrent_action.triggered.connect(self.on_create_torrent_from_files) + + menu.addAction(browse_files_action) + menu.addAction(browse_dir_action) + menu.addAction(add_url_action) + menu.addAction(create_torrent_action) + + menu.exec_(QCursor.pos()) + + def on_create_torrent_from_files(self): + self.window().edit_channel_details_create_torrent.initialize(self.model.channel_id) + self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_CREATE_TORRENT) + + def on_add_torrent_browse_file(self): + filename = QFileDialog.getOpenFileName(self, "Please select the .torrent file", "", "Torrent files (*.torrent)") + if not filename[0]: + return + self.model.add_torrent_to_channel(filename[0]) + + def on_add_torrent_from_url(self): + self.dialog = ConfirmationDialog(self, "Add torrent from URL/magnet link", + "Please enter the URL/magnet link in the field below:", + [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], + show_input=True) + self.dialog.dialog_widget.dialog_input.setPlaceholderText('URL/magnet link') + self.dialog.button_clicked.connect(self.on_torrent_from_url_dialog_done) + self.dialog.show() + + def on_torrent_from_url_dialog_done(self, action): + if action == 0: + url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) + self.model.add_torrent_url_to_channel(url) + self.dialog.close_dialog() + self.dialog = None + + # Commit button-related methods + def clicked_edit_channel_commit_button(self): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel", self.on_channel_committed, + data=u'commit_changes=1'.encode('utf-8'), + method='POST') + + def on_channel_committed(self, result): + if not result: + return + if 'modified' in result: + self.model.refresh() diff --git a/TriblerGUI/widgets/createtorrentpage.py b/TriblerGUI/widgets/createtorrentpage.py index 91b82067273..c4e5c44e14e 100644 --- a/TriblerGUI/widgets/createtorrentpage.py +++ b/TriblerGUI/widgets/createtorrentpage.py @@ -119,7 +119,7 @@ def on_torrent_to_channel_added(self, result): self.window().edit_channel_create_torrent_progress_label.hide() if 'added' in result: self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_TORRENTS) - self.window().edit_channel_page.load_channel_torrents() + self.window().edit_channel_torrents_list.model.refresh() def on_remove_entry(self): self.window().create_torrent_files_list.takeItem(self.selected_item_index) diff --git a/TriblerGUI/widgets/discoveredpage.py b/TriblerGUI/widgets/discoveredpage.py index 6e9874e3e07..08adde1b9e8 100644 --- a/TriblerGUI/widgets/discoveredpage.py +++ b/TriblerGUI/widgets/discoveredpage.py @@ -1,7 +1,8 @@ +from __future__ import absolute_import + from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.channel_list_item import ChannelListItem -from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS class DiscoveredPage(QWidget): @@ -11,42 +12,22 @@ class DiscoveredPage(QWidget): def __init__(self): QWidget.__init__(self) - self.discovered_channels = [] - self.request_mgr = None self.initialized = False def initialize_discovered_page(self): if not self.initialized: - self.window().core_manager.events_manager.discovered_channel.connect(self.on_discovered_channel) self.initialized = True + container = self.window().discovered_channels_container + + container.initialize_model(search_type=u'channel') + container.channel_entry_clicked.connect(self.window().on_channel_clicked) + container.details_tab_widget.setHidden(True) + container.buttons_container.setHidden(True) + container.top_bar_container.setHidden(True) + + container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) + container.torrents_table.setColumnHidden(container.model.column_position[u'health'], True) + container.torrents_table.setColumnHidden(container.model.column_position[ACTION_BUTTONS], True) def load_discovered_channels(self): - self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("channels/discovered", self.received_discovered_channels) - - def received_discovered_channels(self, results): - if not results or 'channels' not in results: - return - - self.discovered_channels = [] - self.window().discovered_channels_list.set_data_items([]) - items = [] - - results['channels'].sort(key=lambda x: x['torrents'], reverse=True) - - for result in results['channels']: - items.append((ChannelListItem, result)) - self.discovered_channels.append(result) - self.update_num_label() - self.window().discovered_channels_list.set_data_items(items) - - def on_discovered_channel(self, channel_info): - channel_info['torrents'] = 0 - channel_info['subscribed'] = False - channel_info['votes'] = 0 - self.window().discovered_channels_list.append_item((ChannelListItem, channel_info)) - self.discovered_channels.append(channel_info) - self.update_num_label() - - def update_num_label(self): - self.window().num_discovered_channels_label.setText("%d items" % len(self.discovered_channels)) + self.window().discovered_channels_container.model.refresh() diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index d1d0e469413..e9005fd6cf4 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -1,47 +1,24 @@ -import base64 -import glob -import os -import urllib -from urllib import pathname2url +from __future__ import absolute_import -from PyQt5.QtCore import Qt, pyqtSignal, QDir -from PyQt5.QtGui import QIcon, QCursor +import os -from PyQt5.QtWidgets import QWidget, QAction, QTreeWidgetItem, QFileDialog +from PyQt5.QtWidgets import QWidget, QFileDialog -from TriblerGUI.tribler_action_menu import TriblerActionMenu -from TriblerGUI.widgets.channel_torrent_list_item import ChannelTorrentListItem from TriblerGUI.defs import PAGE_EDIT_CHANNEL_OVERVIEW, BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, \ - PAGE_EDIT_CHANNEL_PLAYLISTS, PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS, PAGE_EDIT_CHANNEL_PLAYLIST_MANAGE, \ - PAGE_EDIT_CHANNEL_PLAYLIST_EDIT, PAGE_EDIT_CHANNEL_SETTINGS, PAGE_EDIT_CHANNEL_TORRENTS,\ - PAGE_EDIT_CHANNEL_RSS_FEEDS, PAGE_EDIT_CHANNEL_CREATE_TORRENT + PAGE_EDIT_CHANNEL_SETTINGS, PAGE_EDIT_CHANNEL_TORRENTS from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog -from TriblerGUI.widgets.loading_list_item import LoadingListItem -from TriblerGUI.widgets.playlist_list_item import PlaylistListItem from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.utilities import get_image_path -chant_welcome_text = \ -"""Welcome to the management interface of your channel! - -Here, you can change settings of you channel and manage your shared torrents. -Note that this is a New-style channel, which is still experimental.""" class EditChannelPage(QWidget): """ - This class is responsible for managing lists and data on your channel page, including torrents, playlists - and rss feeds. + This class is responsible for managing lists and data on your channel page """ - playlists_loaded = pyqtSignal(object) def __init__(self): QWidget.__init__(self) - self.remove_torrent_requests = [] self.channel_overview = None - self.playlists = None - self.editing_playlist = None - self.viewing_playlist = None self.dialog = None self.editchannel_request_mgr = None @@ -56,37 +33,11 @@ def initialize_edit_channel_page(self): self.window().create_channel_button.clicked.connect(self.on_create_channel_button_pressed) self.window().edit_channel_save_button.clicked.connect(self.on_edit_channel_save_button_pressed) - self.window().edit_channel_torrents_remove_selected_button.clicked.connect( - self.on_torrents_remove_selected_clicked) - self.window().edit_channel_torrents_remove_all_button.clicked.connect(self.on_torrents_remove_all_clicked) - self.window().edit_channel_torrents_add_button.clicked.connect(self.on_torrents_add_clicked) - - self.window().edit_channel_details_playlist_manage.playlist_saved.connect(self.load_channel_playlists) - - self.window().edit_channel_playlist_torrents_back.clicked.connect(self.on_playlist_torrents_back_clicked) - self.window().edit_channel_playlists_list.itemClicked.connect(self.on_playlist_item_clicked) - self.window().edit_channel_playlist_manage_torrents_button.clicked.connect(self.on_playlist_manage_clicked) - self.window().edit_channel_create_playlist_button.clicked.connect(self.on_playlist_created_clicked) - - self.window().playlist_edit_save_button.clicked.connect(self.on_playlist_edit_save_clicked) - self.window().playlist_edit_cancel_button.clicked.connect(self.on_playlist_edit_cancel_clicked) - - self.window().edit_channel_details_rss_feeds_remove_selected_button.clicked.connect( - self.on_rss_feeds_remove_selected_clicked) - self.window().edit_channel_details_rss_add_button.clicked.connect(self.on_rss_feed_add_clicked) - self.window().edit_channel_details_rss_refresh_button.clicked.connect(self.on_rss_feeds_refresh_clicked) - # Tab bar buttons self.window().channel_settings_tab.initialize() self.window().channel_settings_tab.clicked_tab_button.connect(self.clicked_tab_button) - # Chant publish widget is hidden by default and only shown when necessary - self.window().dirty_channel_widget.setHidden(True) - self.window().edit_channel_commit_button.clicked.connect(self.clicked_edit_channel_commit_button) - self.window().export_channel_button.clicked.connect(self.on_export_mdblob) - self.window().export_channel_button.setHidden(True) - def load_my_channel_overview(self): if not self.channel_overview: @@ -104,11 +55,7 @@ def initialize_with_channel_overview(self, overview): return self.channel_overview = overview["mychannel"] - if "chant" in self.channel_overview: - self.window().edit_channel_playlists_button.setHidden(True) - self.window().edit_channel_rss_feeds_button.setHidden(True) - self.window().label_7.setText(chant_welcome_text) - self.window().export_channel_button.setHidden(False) + self.window().export_channel_button.setHidden(False) self.window().edit_channel_name_label.setText("My channel") self.window().edit_channel_overview_name_label.setText(self.channel_overview["name"]) @@ -120,70 +67,9 @@ def initialize_with_channel_overview(self, overview): self.window().edit_channel_stacked_widget.setCurrentIndex(1) - def load_channel_torrents(self): - self.window().edit_channel_torrents_list.set_data_items([(LoadingListItem, None)]) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/torrents?disable_filter=1" % - self.channel_overview["identifier"], self.initialize_with_torrents) - - def initialize_with_torrents(self, torrents): - if not torrents: - return - self.window().edit_channel_torrents_list.set_data_items([]) - - self.window().dirty_channel_widget.setHidden(not("chant_dirty" in torrents and torrents["chant_dirty"])) - items = [] - for result in torrents['torrents']: - items.append((ChannelTorrentListItem, result, - {"show_controls": True, "on_remove_clicked": self.on_torrent_remove_clicked})) - self.window().edit_channel_torrents_list.set_data_items(items) - - def load_channel_playlists(self): - self.window().edit_channel_playlists_list.set_data_items([(LoadingListItem, None)]) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/playlists?disable_filter=1" % - self.channel_overview["identifier"], - self.initialize_with_playlists) - - def initialize_with_playlists(self, playlists): - if not playlists: - return - self.playlists_loaded.emit(playlists) - self.playlists = playlists - self.window().edit_channel_playlists_list.set_data_items([]) - - self.update_playlist_list() - - viewing_playlist_index = self.get_index_of_viewing_playlist() - if viewing_playlist_index != -1: - self.viewing_playlist = self.playlists['playlists'][viewing_playlist_index] - self.update_playlist_torrent_list() - - def load_channel_rss_feeds(self): - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/rssfeeds" % - self.channel_overview["identifier"], - self.initialize_with_rss_feeds) - - def initialize_with_rss_feeds(self, rss_feeds): - if not rss_feeds: - return - self.window().edit_channel_rss_feeds_list.clear() - for feed in rss_feeds["rssfeeds"]: - item = QTreeWidgetItem(self.window().edit_channel_rss_feeds_list) - item.setText(0, feed["url"]) - - self.window().edit_channel_rss_feeds_list.addTopLevelItem(item) - - def on_torrent_remove_clicked(self, item): - if "chant" in self.channel_overview: - self.on_torrents_remove_selected_action(0, item) - return - self.dialog = ConfirmationDialog(self, "Remove selected torrent", - "Are you sure that you want to remove the selected torrent from this channel?", - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(lambda action: self.on_torrents_remove_selected_action(action, item)) - self.dialog.show() + self.window().edit_channel_torrents_list.initialize_model(self.channel_overview["identifier"]) + self.window().edit_channel_torrents_list.torrents_table.setColumnHidden( + self.window().edit_channel_torrents_list.model.column_position[u'subscribed'], True) def on_create_channel_button_pressed(self): channel_name = self.window().new_channel_name_edit.text() @@ -195,8 +81,8 @@ def on_create_channel_button_pressed(self): self.window().create_channel_button.setEnabled(False) self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("channels/discovered", self.on_channel_created, - data=unicode('name=%s&description=%s' % - (channel_name, channel_description)).encode('utf-8'), + data=(u'name=%s&description=%s' % + (channel_name, channel_description)).encode('utf-8'), method='PUT') def on_channel_created(self, result): @@ -212,22 +98,10 @@ def on_edit_channel_save_button_pressed(self): self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_edited, - data=unicode('name=%s&description=%s' % - (channel_name, channel_description)).encode('utf-8'), + data=(u'name=%s&description=%s' % + (channel_name, channel_description)).encode('utf-8'), method='POST') - def clicked_edit_channel_commit_button(self): - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_committed, - data=unicode('commit_changes=1').encode('utf-8'), - method='POST') - - def on_channel_committed(self, result): - if not result: - return - if 'modified' in result: - self.load_channel_torrents() - def on_channel_edited(self, result): if not result: return @@ -236,302 +110,6 @@ def on_channel_edited(self, result): self.window().edit_channel_description_label.setText( self.window().edit_channel_description_edit.toPlainText()) - def on_torrents_remove_selected_clicked(self): - num_selected = len(self.window().edit_channel_torrents_list.selectedItems()) - if num_selected == 0: - return - - selected_torrent_items = [self.window().edit_channel_torrents_list.itemWidget(list_widget_item) - for list_widget_item in self.window().edit_channel_torrents_list.selectedItems()] - - self.dialog = ConfirmationDialog(self, "Remove %s selected torrents" % num_selected, - "Are you sure that you want to remove %s selected torrents " - "from your channel?" % num_selected, - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(lambda action: - self.on_torrents_remove_selected_action(action, selected_torrent_items)) - self.dialog.show() - - def on_torrents_remove_all_clicked(self): - self.dialog = ConfirmationDialog(self.window(), "Remove all torrents", - "Are you sure that you want to remove all torrents from your channel? " - "You cannot undo this action.", - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(self.on_torrents_remove_all_action) - self.dialog.show() - - def on_torrents_add_clicked(self): - menu = TriblerActionMenu(self) - - browse_files_action = QAction('Import torrent from file', self) - browse_dir_action = QAction('Import torrent(s) from dir', self) - add_url_action = QAction('Add URL', self) - create_torrent_action = QAction('Create torrent from file(s)', self) - - browse_files_action.triggered.connect(self.on_add_torrent_browse_file) - browse_dir_action.triggered.connect(self.on_add_torrents_browse_dir) - add_url_action.triggered.connect(self.on_add_torrent_from_url) - create_torrent_action.triggered.connect(self.on_create_torrent_from_files) - - menu.addAction(browse_files_action) - menu.addAction(browse_dir_action) - menu.addAction(add_url_action) - menu.addAction(create_torrent_action) - - menu.exec_(QCursor.pos()) - - def add_torrent_to_channel(self, filename): - with open(filename, "rb") as torrent_file: - torrent_content = urllib.quote_plus(base64.b64encode(torrent_file.read())) - editchannel_request_mgr = TriblerRequestManager() - editchannel_request_mgr.perform_request("channels/discovered/%s/torrents" % - self.channel_overview['identifier'], - self.on_torrent_to_channel_added, method='PUT', - data='torrent=%s' % torrent_content) - - def on_add_torrent_browse_file(self): - filename = QFileDialog.getOpenFileName(self, "Please select the .torrent file", "", "Torrent files (*.torrent)") - if len(filename[0]) == 0: - return - self.add_torrent_to_channel(filename[0]) - - - def on_add_torrent_from_url(self): - self.dialog = ConfirmationDialog(self, "Add torrent from URL/magnet link", - "Please enter the URL/magnet link in the field below:", - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - show_input=True) - self.dialog.dialog_widget.dialog_input.setPlaceholderText('URL/magnet link') - self.dialog.button_clicked.connect(self.on_torrent_from_url_dialog_done) - self.dialog.show() - - def on_torrent_from_url_dialog_done(self, action): - if action == 0: - url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (self.channel_overview['identifier'], url), - self.on_torrent_to_channel_added, method='PUT') - self.dialog.close_dialog() - self.dialog = None - - def on_torrent_to_channel_added(self, result): - if not result: - return - if 'added' in result: - self.load_channel_torrents() - - def on_create_torrent_from_files(self): - self.window().edit_channel_details_create_torrent.initialize(self.channel_overview['identifier']) - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_CREATE_TORRENT) - - def on_playlist_torrents_back_clicked(self): - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLISTS) - - def on_playlist_item_clicked(self, item): - playlist_info = item.data(Qt.UserRole) - if not playlist_info: - return - self.window().edit_channel_playlist_torrents_list.set_data_items([]) - self.window().edit_channel_details_playlist_torrents_header.setText("Torrents in '%s'" % playlist_info['name']) - self.window().edit_channel_playlist_torrents_back.setIcon(QIcon(get_image_path('page_back.png'))) - - self.viewing_playlist = playlist_info - self.update_playlist_torrent_list() - - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS) - - def update_playlist_list(self): - self.playlists['playlists'].sort(key=lambda torrent: len(torrent['torrents']), reverse=True) - - items = [] - for result in self.playlists['playlists']: - items.append((PlaylistListItem, result, - {"show_controls": True, "on_remove_clicked": self.on_playlist_remove_clicked, - "on_edit_clicked": self.on_playlist_edit_clicked})) - self.window().edit_channel_playlists_list.set_data_items(items) - - def update_playlist_torrent_list(self): - items = [] - for torrent in self.viewing_playlist["torrents"]: - items.append((ChannelTorrentListItem, torrent, - {"show_controls": True, "on_remove_clicked": self.on_playlist_torrent_remove_clicked})) - self.window().edit_channel_playlist_torrents_list.set_data_items(items) - - def on_playlist_manage_clicked(self): - self.window().edit_channel_details_playlist_manage.initialize(self.channel_overview, self.viewing_playlist) - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_MANAGE) - - def on_playlist_torrent_remove_clicked(self, item): - self.dialog = ConfirmationDialog(self, - "Remove selected torrent from playlist", - "Are you sure that you want to remove the selected torrent " - "from this playlist?", - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(lambda action: self.on_playlist_torrent_remove_selected_action(item, action)) - self.dialog.show() - - def on_playlist_torrent_remove_selected_action(self, item, action): - if action == 0: - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/playlists/%s/%s" % - (self.channel_overview["identifier"], - self.viewing_playlist['id'], item.torrent_info['infohash']), - lambda result: self.on_playlist_torrent_removed( - result, item.torrent_info), - method='DELETE') - - self.dialog.close_dialog() - self.dialog = None - - def on_playlist_torrent_removed(self, result, torrent): - if not result: - return - self.remove_torrent_from_playlist(torrent) - - def get_index_of_viewing_playlist(self): - if self.viewing_playlist is None: - return -1 - - for index in xrange(len(self.playlists['playlists'])): - if self.playlists['playlists'][index]['id'] == self.viewing_playlist['id']: - return index - - return -1 - - def remove_torrent_from_playlist(self, torrent): - playlist_index = self.get_index_of_viewing_playlist() - - torrent_index = -1 - for index in xrange(len(self.viewing_playlist['torrents'])): - if self.viewing_playlist['torrents'][index]['infohash'] == torrent['infohash']: - torrent_index = index - break - - if torrent_index != -1: - del self.playlists['playlists'][playlist_index]['torrents'][torrent_index] - self.viewing_playlist = self.playlists['playlists'][playlist_index] - self.update_playlist_list() - self.update_playlist_torrent_list() - - def on_playlist_edit_save_clicked(self): - if len(self.window().playlist_edit_name.text()) == 0: - return - - name = self.window().playlist_edit_name.text() - description = self.window().playlist_edit_description.toPlainText() - - self.editchannel_request_mgr = TriblerRequestManager() - if self.editing_playlist is None: - self.editchannel_request_mgr.perform_request("channels/discovered/%s/playlists" % - self.channel_overview["identifier"], self.on_playlist_created, - data=unicode('name=%s&description=%s' % - (name, description)).encode('utf-8'), - method='PUT') - else: - self.editchannel_request_mgr.perform_request("channels/discovered/%s/playlists/%s" % - (self.channel_overview["identifier"], - self.editing_playlist["id"]), self.on_playlist_edited, - data=unicode('name=%s&description=%s' % - (name, description)).encode('utf-8'), - method='POST') - - def on_playlist_created(self, json_result): - if not json_result: - return - if 'created' in json_result and json_result['created']: - self.on_playlist_edited_done() - - def on_playlist_edited(self, json_result): - if not json_result: - return - if 'modified' in json_result and json_result['modified']: - self.on_playlist_edited_done() - - def on_playlist_edited_done(self): - self.window().playlist_edit_name.setText('') - self.window().playlist_edit_description.setText('') - self.load_channel_playlists() - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLISTS) - - def on_playlist_edit_cancel_clicked(self): - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLISTS) - - def on_playlist_created_clicked(self): - self.editing_playlist = None - self.window().playlist_edit_save_button.setText("CREATE") - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_EDIT) - - def on_playlist_remove_clicked(self, item): - self.dialog = ConfirmationDialog(self, "Remove selected playlist", - "Are you sure that you want to remove the selected playlist " - "from your channel?", - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(lambda action: self.on_playlist_remove_selected_action(item, action)) - self.dialog.show() - - def on_playlist_remove_selected_action(self, item, action): - if action == 0: - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/playlists/%s" % - (self.channel_overview["identifier"], - item.playlist_info['id']), - self.on_playlist_removed, method='DELETE') - - self.dialog.close_dialog() - self.dialog = None - - def on_playlist_removed(self, json_result): - if not json_result: - return - if 'removed' in json_result and json_result['removed']: - self.load_channel_playlists() - - def on_playlist_edit_clicked(self, item): - self.editing_playlist = item.playlist_info - self.window().playlist_edit_save_button.setText("CREATE") - self.window().playlist_edit_name.setText(item.playlist_info["name"]) - self.window().playlist_edit_description.setText(item.playlist_info["description"]) - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_EDIT) - - def on_torrents_remove_selected_action(self, action, items): - if action == 0: - if isinstance(items, list): - infohash = ",".join([torrent_item.torrent_info['infohash'] for torrent_item in items]) - else: - infohash = items.torrent_info['infohash'] - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (self.channel_overview["identifier"], - infohash), - self.on_torrent_removed, method='DELETE') - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - def on_torrent_removed(self, json_result): - if not json_result: - return - if 'removed' in json_result and json_result['removed']: - self.load_channel_torrents() - - def on_torrents_remove_all_action(self, action): - if action == 0: - for torrent_ind in xrange(self.window().edit_channel_torrents_list.count()): - torrent_data = self.window().edit_channel_torrents_list.item(torrent_ind).data(Qt.UserRole) - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (self.channel_overview["identifier"], torrent_data['infohash']), - None, method='DELETE') - self.remove_torrent_requests.append(request_mgr) - - self.window().edit_channel_torrents_list.set_data_items([]) - if "chant" in self.channel_overview: - self.load_channel_torrents() - - self.dialog.close_dialog() - self.dialog = None - def clicked_tab_button(self, tab_button_name): if tab_button_name == "edit_channel_overview_button": self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_OVERVIEW) @@ -539,88 +117,15 @@ def clicked_tab_button(self, tab_button_name): self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_SETTINGS) elif tab_button_name == "edit_channel_torrents_button": self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_TORRENTS) - self.load_channel_torrents() - elif tab_button_name == "edit_channel_playlists_button": - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLISTS) - self.load_channel_playlists() - elif tab_button_name == "edit_channel_rss_feeds_button": - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_RSS_FEEDS) - self.load_channel_rss_feeds() def on_create_channel_intro_button_clicked(self): self.window().create_channel_form.show() self.window().create_channel_intro_button_container.hide() self.window().create_new_channel_intro_label.setText("Please enter your channel details below.") - def on_rss_feed_add_clicked(self): - self.dialog = ConfirmationDialog(self, "Add RSS feed", "Please enter the RSS feed URL in the field below:", - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - show_input=True) - self.dialog.dialog_widget.dialog_input.setPlaceholderText('RSS feed URL') - self.dialog.button_clicked.connect(self.on_rss_feed_dialog_added) - self.dialog.show() - - def on_rss_feed_dialog_added(self, action): - if action == 0: - url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/rssfeeds/%s" % - (self.channel_overview["identifier"], url), - self.on_rss_feed_added, method='PUT') - - self.dialog.close_dialog() - self.dialog = None - - def on_rss_feed_added(self, json_result): - if not json_result: - return - if json_result['added']: - self.load_channel_rss_feeds() - - def on_rss_feeds_remove_selected_clicked(self): - if len(self.window().edit_channel_rss_feeds_list.selectedItems()) == 0: - ConfirmationDialog.show_message(self, "Remove RSS Feeds", - "Selection is empty. Please select the feeds to remove.", "OK") - return - self.dialog = ConfirmationDialog(self, "Remove RSS feed", - "Are you sure you want to remove the selected RSS feed?", - [('REMOVE', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(self.on_rss_feed_dialog_removed) - self.dialog.show() - - def on_rss_feed_dialog_removed(self, action): - if action == 0: - url = urllib.quote_plus(self.window().edit_channel_rss_feeds_list.selectedItems()[0].text(0)) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered/%s/rssfeeds/%s" % - (self.channel_overview["identifier"], url), - self.on_rss_feed_removed, method='DELETE') - - self.dialog.close_dialog() - self.dialog = None - - def on_rss_feed_removed(self, json_result): - if not json_result: - return - if json_result['removed']: - self.load_channel_rss_feeds() - - def on_rss_feeds_refresh_clicked(self): - self.window().edit_channel_details_rss_refresh_button.setEnabled(False) - self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request('channels/discovered/%s/recheckfeeds' % - self.channel_overview["identifier"], self.on_rss_feeds_refreshed,\ - method='POST') - - def on_rss_feeds_refreshed(self, json_result): - if not json_result: - return - if json_result["rechecked"]: - self.window().edit_channel_details_rss_refresh_button.setEnabled(True) - def on_export_mdblob(self): - - export_dir = QFileDialog.getExistingDirectory(self, "Please select the destination directory", "", QFileDialog.ShowDirsOnly) + export_dir = QFileDialog.getExistingDirectory(self, "Please select the destination directory", "", + QFileDialog.ShowDirsOnly) if len(export_dir) == 0: return @@ -628,9 +133,9 @@ def on_export_mdblob(self): # Show confirmation dialog where we specify the name of the file mdblob_name = self.channel_overview["identifier"] dialog = ConfirmationDialog(self, "Export mdblob file", - "Please enter the name of the channel metadata file:", - [('SAVE', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - show_input=True) + "Please enter the name of the channel metadata file:", + [('SAVE', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], + show_input=True) def on_export_download_dialog_done(action): if action == 0: @@ -658,32 +163,3 @@ def on_export_download_request_done(dest_path, data): dialog.dialog_widget.dialog_input.setFocus() dialog.button_clicked.connect(on_export_download_dialog_done) dialog.show() - - - def on_add_torrents_browse_dir(self): - chosen_dir = QFileDialog.getExistingDirectory(self, - "Please select the directory containing the .torrent files", - QDir.homePath(), - QFileDialog.ShowDirsOnly) - if len(chosen_dir) == 0: - return - - self.selected_torrent_files = [torrent_file for torrent_file in glob.glob(chosen_dir + "/*.torrent")] - self.dialog = ConfirmationDialog(self, "Add torrents from directory", - "Are you sure you want to add %d torrents to your Tribler channel?" % - len(self.selected_torrent_files), - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(self.on_confirm_add_directory_dialog) - self.dialog.show() - - def on_confirm_add_directory_dialog(self, action): - if action == 0: - for filename in self.selected_torrent_files: - self.add_torrent_to_channel(filename) - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - - diff --git a/TriblerGUI/widgets/homepage.py b/TriblerGUI/widgets/homepage.py index cfacc62b52c..b97eb35c06b 100644 --- a/TriblerGUI/widgets/homepage.py +++ b/TriblerGUI/widgets/homepage.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from PyQt5.QtWidgets import QWidget from TriblerGUI.defs import PAGE_CHANNEL_DETAILS diff --git a/TriblerGUI/widgets/lazyloadlist.py b/TriblerGUI/widgets/lazyloadlist.py deleted file mode 100644 index 0c9f8347a48..00000000000 --- a/TriblerGUI/widgets/lazyloadlist.py +++ /dev/null @@ -1,79 +0,0 @@ -from PyQt5.QtCore import QSize, Qt -from PyQt5.QtWidgets import QListWidget, QListWidgetItem, QAbstractItemView - -from TriblerGUI.widgets.channel_torrent_list_item import ChannelTorrentListItem - -ITEM_LOAD_BATCH = 30 - - -class LazyLoadList(QListWidget): - """ - This class implements a list where widget items are lazy-loaded. When the user has reached the end of the list - when scrolling, the next items are created and displayed. - """ - def __init__(self, parent): - QListWidget.__init__(self, parent) - self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.verticalScrollBar().valueChanged.connect(self.on_list_scroll) - self.itemSelectionChanged.connect(self.on_item_clicked) - self.data_items = [] # Tuple of (ListWidgetClass, json data) - - def load_next_items(self): - for i in range(self.count(), min(self.count() + ITEM_LOAD_BATCH, len(self.data_items))): - self.load_item(i) - - def load_item(self, index): - if index < len(self.data_items): - item = QListWidgetItem() - item.setSizeHint(QSize(-1, 60)) - data_item = self.data_items[index] - item.setData(Qt.UserRole, data_item[1]) - if len(data_item) > 2: - widget_item = data_item[0](self, data_item[1], **data_item[2]) - else: - widget_item = data_item[0](self, data_item[1]) - self.insertItem(index, item) - self.setItemWidget(item, widget_item) - - def insert_item(self, index, item): - self.data_items.insert(index, item) - if index < ITEM_LOAD_BATCH: - self.load_item(index) - - def set_data_items(self, items): - self.clear() - self.data_items = items - self.load_next_items() - - def append_item(self, item): - self.data_items.append(item) - if self.count() < ITEM_LOAD_BATCH: - self.load_item(self.count()) - - def on_list_scroll(self, event): - if self.verticalScrollBar().value() == self.verticalScrollBar().maximum(): - self.load_next_items() - - def get_first_items(self, num, cls=None): - """ - Return the first num widget items with type cls. - This can be useful when for instance you need the first five search results. - """ - result = [] - for i in xrange(self.count()): - widget_item = self.itemWidget(self.item(i)) - if not cls or (cls and isinstance(widget_item, cls)): - result.append(widget_item) - - if len(result) >= num: - break - - return result - - def on_item_clicked(self): - if len(self.selectedItems()) == 0: - return - - for item_widget in (self.itemWidget(widget) for widget in self.selectedItems()): - if isinstance(item_widget, ChannelTorrentListItem): - item_widget.check_health() diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py new file mode 100644 index 00000000000..be69e90e962 --- /dev/null +++ b/TriblerGUI/widgets/lazytableview.py @@ -0,0 +1,629 @@ +from __future__ import absolute_import, division + +from PyQt5 import QtCore +from abc import abstractmethod + +from PyQt5.QtCore import QModelIndex, pyqtSignal, QPoint, QRect, Qt, QObject, QSize +from PyQt5.QtGui import QIcon, QPen, QColor, QPainter, QBrush +from PyQt5.QtWidgets import QTableView, QStyledItemDelegate, QStyle + +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, COMMITTED, NEW +from Tribler.Core.Modules.restapi.util import CATEGORY_CHANNEL, CATEGORY_OLD_CHANNEL, HEALTH_MOOT, HEALTH_DEAD, \ + HEALTH_GOOD, HEALTH_UNCHECKED, HEALTH_CHECKING, HEALTH_ERROR +from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.utilities import get_image_path + +ACTION_BUTTONS = u'action_buttons' + + +class RemoteTableModel(QtCore.QAbstractTableModel): + + def __init__(self, parent=None): + super(RemoteTableModel, self).__init__() + self.data_items = [] + self.item_load_batch = 30 + self.sort_column = 0 + self.sort_order = 0 + + self.load_next_items() + + @abstractmethod + def _get_remote_data(self, start, end): + # This must call self._on_new_items_received as a callback when data received + pass + + @abstractmethod + def _set_remote_data(self): + pass + + def refresh(self): + self.beginResetModel() + self.data_items = [] + self.endResetModel() + self.load_next_items() + + def sort(self, column, order): + self.sort_order = not order + self.sort_column = column + self.refresh() + + def load_next_items(self): + self._get_remote_data(self.rowCount(), self.rowCount() + self.item_load_batch, + sort_column=self.sort_column, + sort_order=self.sort_order) + + def _on_new_items_received(self, new_data_items): + # If we want to block the signal like itemChanged, we must use QSignalBlocker object + old_end = self.rowCount() + new_end = self.rowCount() + len(new_data_items) + if old_end == new_end: + return + self.beginInsertRows(QModelIndex(), old_end, new_end - 1) + self.data_items.extend(new_data_items) + self.endInsertRows() + + +class LazyTableView(QTableView): + def __init__(self, parent=None): + super(LazyTableView, self).__init__(parent) + self.verticalScrollBar().valueChanged.connect(self._on_list_scroll) + self.setSortingEnabled(True) + + def _on_list_scroll(self, event): + if self.verticalScrollBar().value() == self.verticalScrollBar().maximum() and \ + self.model().data_items: # workaround for duplicate calls to _on_list_scroll on view creation + self.model().load_next_items() + + +class ChannelsTableView(LazyTableView): + # TODO: add redraw when the mouse leaves the view through the header + # overloading leaveEvent method could be used for that + mouse_moved = pyqtSignal(QPoint, QModelIndex) + + def __init__(self, parent=None): + super(ChannelsTableView, self).__init__(parent) + self.verticalHeader().setDefaultSectionSize(40) + self.setShowGrid(False) + + delegate = ChannelsButtonsDelegate() + self.setMouseTracking(True) + self.setItemDelegate(delegate) + self.mouse_moved.connect(delegate.on_mouse_moved) + delegate.redraw_required.connect(self.redraw) + delegate.play_button.clicked.connect(self.on_play_button_clicked) + delegate.download_button.clicked.connect(self.on_download_button_clicked) + delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) + delegate.commit_control.clicked.connect(self.on_commit_control_clicked) + + def on_subscribe_control_clicked(self, index): + status = int(index.model().data_items[index.row()][u'subscribed']) + if status: + self.on_unsubscribe_button_clicked(index) + else: + self.on_subscribe_button_clicked(index) + index.model().data_items[index.row()][u'subscribed'] = int(not status) + + def mouseMoveEvent(self, event): + index = QModelIndex(self.indexAt(event.pos())) + self.mouse_moved.emit(event.pos(), index) + + def redraw(self): + self.viewport().update() + + def on_play_button_clicked(self, index): + infohash = index.model().data_items[index.row()][u'infohash'] + + def on_play_request_done(_): + if not self: + return + self.window().left_menu_button_video_player.click() + self.window().video_player_page.play_media_item(infohash, -1) + + self.window().perform_start_download_request(index2uri(index), + self.window().tribler_settings['download_defaults'][ + 'anonymity_enabled'], + self.window().tribler_settings['download_defaults'][ + 'safeseeding_enabled'], + self.window().tribler_settings['download_defaults']['saveas'], + [], 0, callback=on_play_request_done) + + def on_download_button_clicked(self, index): + self.window().start_download_from_uri(index2uri(index)) + + def on_subscribe_button_clicked(self, index): + public_key = index.model().data_items[index.row()][u'public_key'] + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/subscribed/%s" % public_key, + self.on_channel_subscribed, method='PUT') + + def on_unsubscribe_button_clicked(self, index): + public_key = index.model().data_items[index.row()][u'public_key'] + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/subscribed/%s" % public_key, + self.on_channel_unsubscribed, method='DELETE') + + def on_commit_control_clicked(self, index): + infohash = index.model().data_items[index.row()][u'infohash'] + channel_id = index.model().data_items[index.row()][u'public_key'] + status = index.model().data_items[index.row()][u'commit_status'] + + request_mgr = TriblerRequestManager() + request_mgr.perform_request("channels/discovered/%s/torrents/%s" % + (channel_id, infohash) + \ + ("?restore=1" if status == TODELETE else ''), + self.on_torrent_removed, method='DELETE') + + def on_torrent_removed(self, json_result): + if not json_result: + return + if 'removed' in json_result and json_result['removed']: + self.model().refresh() + + def on_channel_subscribed(self, *args): + pass + + def on_channel_unsubscribed(self, *args): + pass + + +class ChannelsButtonsDelegate(QStyledItemDelegate): + redraw_required = pyqtSignal() + + def __init__(self, parent=None): + super(ChannelsButtonsDelegate, self).__init__(parent) + self.no_index = QModelIndex() + self.hoverrow = None + self.hover_index = None + + # We have to control if mouse is in the buttons box to add some tolerance for vertical mouse + # misplacement around the buttons. The button box effectively overlaps upper and lower rows. + # row 0 + # --------- <- tolerance zone + # row 1 |buttons| + # --------- <- tolerance zone + # row 2 + # button_box_extended_border_ration controls the thickness of the tolerance zone + self.button_box = QRect() + self.button_box_extended_border_ratio = float(0.3) + + # On-demand buttons + self.play_button = PlayIconButton() + self.download_button = DownloadIconButton() + + self.ondemand_container = [self.play_button, self.download_button] + self.subscribe_control = ToggleControl(u'subscribed', + QIcon(get_image_path("subscribed_yes.png")), + QIcon(get_image_path("subscribed_not.png")), + QIcon(get_image_path("subscribed.png"))) + self.commit_control = CommitStatusControl(u'commit_status') + # self.mine_button = MineIconButton() + + self.health_status = HealthStatusDisplay() + + self.controls = [self.play_button, self.download_button, self.subscribe_control, self.commit_control] + + def on_mouse_moved(self, pos, index): + # This method controls for which rows the buttons/box should be drawn + redraw = False + if self.hover_index != index: + self.hover_index = index + self.hoverrow = index.row() + if not self.button_box.contains(pos): + redraw = True + # Redraw when the mouse leaves the table + if index.row() == -1 and self.hoverrow != -1: + self.hoverrow = -1 + redraw = True + + for controls in self.controls: + redraw = controls.on_mouse_moved(pos, index) or redraw + + if redraw: + # TODO: optimize me to only redraw the rows that actually changed! + self.redraw_required.emit() + + def paint(self, painter, option, index): + # Draw 'hover' state highlight for every cell of a row + if index.row() == self.hoverrow: + option.state |= QStyle.State_MouseOver + + # Draw 'health' column + if index.column() == index.model().column_position[u'health']: + # Draw empty cell as the background + super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) + + self.health_status.paint(painter, option.rect, index) + + # Draw 'commit_status' column + elif index.column() == index.model().column_position[u'commit_status']: + # Draw empty cell as the background + super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) + + if index == self.hover_index: + self.commit_control.paint_hover(painter, option.rect, index) + else: + self.commit_control.paint(painter, option.rect, index) + + # Draw 'category' column + elif index.column() == index.model().column_position[u'category']: + # Draw empty cell as the background + super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) + + painter.save() + + lines = QPen(QColor("#B5B5B5"), 1, Qt.SolidLine, Qt.RoundCap) + painter.setPen(lines) + + text = index.model().data_items[index.row()][u'category'] + text_flags = Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine + text_box = painter.boundingRect(option.rect, text_flags, text) + + painter.drawText(text_box, text_flags, text) + bezel_thickness = 4 + bezel_box = QRect(text_box.left() - bezel_thickness, + text_box.top() - bezel_thickness, + text_box.width() + bezel_thickness * 2, + text_box.height() + bezel_thickness * 2) + + painter.setRenderHint(QPainter.Antialiasing) + painter.drawRoundedRect(bezel_box, 20, 80, mode=Qt.RelativeSize) + + painter.restore() + + # Draw 'subscribed' column + elif index.column() == index.model().column_position[u'subscribed']: + # Draw empty cell as the background + super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) + + if index == self.hover_index: + self.subscribe_control.paint_hover(painter, option.rect, index) + else: + self.subscribe_control.paint(painter, option.rect, index) + + # Draw buttons in the ACTION_BUTTONS column + elif index.column() == index.model().column_position[ACTION_BUTTONS]: + # Draw empty cell as the background + super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) + + # When the cursor leaves the table, we must "forget" about the button_box + if self.hoverrow == -1: + self.button_box = QRect() + if index.row() == self.hoverrow: + extended_border_height = int(option.rect.height() * self.button_box_extended_border_ratio) + button_box_extended_rect = option.rect.adjusted(0, -extended_border_height, + 0, extended_border_height) + self.button_box = button_box_extended_rect + + active_buttons = [b for b in self.ondemand_container if b.should_draw(index)] + if active_buttons: + for rect, button in split_rect_into_squares(button_box_extended_rect, active_buttons): + button.paint(painter, rect, index) + else: + # Draw the rest of the columns + super(ChannelsButtonsDelegate, self).paint(painter, option, index) + + def sizeHint(self, option, index): + if index.column() == index.model().column_position[u'subscribed']: + return self.subscribe_control.size_hint(option, index) + + def editorEvent(self, event, model, option, index): + for control in self.controls: + result = control.check_clicked(event, model, option, index) + if result: + return result + return False + + def createEditor(self, parent, option, index): + # Add null editor to action buttons column + if index.column() == index.model().column_position[ACTION_BUTTONS]: + return + if index.column() == index.model().column_position[u'subscribed']: + return + + super(ChannelsButtonsDelegate, self).createEditor(parent, option, index) + + +def index2uri(index): + infohash = index.model().data_items[index.row()][u'infohash'] + name = index.model().data_items[index.row()][u'name'] + return u"magnet:?xt=urn:btih:%s&dn=%s" % (infohash, name) + + +def split_rect_into_squares(rect, buttons): + r = rect + side_size = min(r.width() / len(buttons), r.height() - 2) + y_border = (r.height() - side_size) / 2 + for n, button in enumerate(buttons): + x = r.left() + n * side_size + y = r.top() + y_border + h = side_size + w = side_size + yield QRect(x, y, w, h), button + + +def index_is_channel(index): + return (index.model().data_items[index.row()][u'category'] == CATEGORY_CHANNEL or + index.model().data_items[index.row()][u'category'] == CATEGORY_OLD_CHANNEL) + + +class IconButton(QObject): + icon = QIcon() + icon_border_ratio = float(0.1) + clicked = pyqtSignal(QModelIndex) + + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + def __init__(self, parent=None): + super(IconButton, self).__init__(parent=parent) + # rect property contains the active zone for the button + self.rect = QRect() + self.icon_rect = QRect() + self.icon_mode = QIcon.Normal + + def should_draw(self, index): + return True + + def paint(self, painter, rect, index): + # Update button activation rect from the drawing call + self.rect = rect + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + self.icon.paint(painter, icon_rect, mode=self.icon_mode) + + def check_clicked(self, event, model, option, index): + if event.type() == QtCore.QEvent.MouseButtonRelease and \ + self.rect.contains(event.pos()): + self.clicked.emit(index) + return True + return False + + def on_mouse_moved(self, pos, index): + old_icon_mode = self.icon_mode + if self.rect.contains(pos): + self.icon_mode = QIcon.Selected + else: + self.icon_mode = QIcon.Normal + return old_icon_mode != self.icon_mode + + def size_hint(self, option, index): + return self.size + + +class DownloadIconButton(IconButton): + icon = QIcon(get_image_path("downloads.png")) + + def should_draw(self, index): + return not index_is_channel(index) + + +class PlayIconButton(IconButton): + icon = QIcon(get_image_path("play.png")) + + def should_draw(self, index): + return index.model().data_items[index.row()][u'category'] == u'Video' + + +class ToggleControl(QObject): + # Column-level controls are stateless collections of methods for visualizing cell data and + # triggering corresponding events. + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + clicked = pyqtSignal(QModelIndex) + + def __init__(self, column_name, on_icon, off_icon, hover_icon, parent=None): + super(ToggleControl, self).__init__(parent=parent) + self.on_icon = on_icon + self.off_icon = off_icon + self.hover_icon = hover_icon + self.column_name = column_name + self.last_index = QModelIndex() + + def paint(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + state = 1 == int(data_item[self.column_name]) + icon = self.on_icon if state else self.off_icon + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + + def paint_hover(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + icon = self.hover_icon + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + + def check_clicked(self, event, model, option, index): + data_item = index.model().data_items[index.row()] + if event.type() == QtCore.QEvent.MouseButtonRelease and \ + index.model().column_position[self.column_name] == index.column() and \ + data_item[self.column_name] != '': + self.clicked.emit(index) + return True + return False + + def size_hint(self, option, index): + return self.size + + def on_mouse_moved(self, pos, index): + if self.last_index != index: + # Handle the case when the cursor leaves the table + if not index.model() or (index.model().column_position[self.column_name] == index.column()): + self.last_index = index + return True + return False + + +class CommitStatusControl(QObject): + # Column-level controls are stateless collections of methods for visualizing cell data and + # triggering corresponding events. + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + clicked = pyqtSignal(QModelIndex) + new_icon = QIcon(get_image_path("plus.svg")) + committed_icon = QIcon(get_image_path("check.svg")) + todelete_icon = QIcon(get_image_path("minus.svg")) + + delete_action_icon = QIcon(get_image_path("delete.png")) + restore_action_icon = QIcon(get_image_path("undo.svg")) + + def __init__(self, column_name, parent=None): + super(CommitStatusControl, self).__init__(parent=parent) + self.column_name = column_name + self.rect = QRect() + self.last_index = QModelIndex() + + def paint(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + state = data_item[self.column_name] + icon = QIcon() + if state == COMMITTED: + icon = self.committed_icon + elif state == NEW: + icon = self.new_icon + elif state == TODELETE: + icon = self.todelete_icon + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + self.rect = rect + + def paint_hover(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + state = data_item[self.column_name] + icon = QIcon() + + if state == COMMITTED: + icon = self.delete_action_icon + elif state == NEW: + icon = self.delete_action_icon + elif state == TODELETE: + icon = self.restore_action_icon + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + self.rect = rect + + def check_clicked(self, event, model, option, index): + data_item = index.model().data_items[index.row()] + if event.type() == QtCore.QEvent.MouseButtonRelease and \ + index.model().column_position[self.column_name] == index.column() and \ + data_item[self.column_name] != '': + self.clicked.emit(index) + return True + return False + + def size_hint(self, option, index): + return self.size + + def on_mouse_moved(self, pos, index): + if self.last_index != index: + # Handle the case when the cursor leaves the table + if not index.model(): + self.last_index = index + return True + elif index.model().column_position[self.column_name] == index.column(): + self.last_index = index + return True + return False + + +class HealthStatusDisplay(QObject): + indicator_side = 10 + indicator_border = 2 + health_colors = { + HEALTH_GOOD: QColor(Qt.green), + HEALTH_DEAD: QColor(Qt.red), + HEALTH_MOOT: QColor(Qt.yellow), + HEALTH_UNCHECKED: QColor("#B5B5B5"), + HEALTH_CHECKING: QColor(Qt.blue), + HEALTH_ERROR: QColor(Qt.cyan) + + } + + def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, alignment=Qt.AlignVCenter): + painter.save() + text_flags = Qt.AlignHCenter | alignment | Qt.TextSingleLine + text_box = painter.boundingRect(rect, text_flags, text) + painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap)) + if font: + painter.setFont(font) + + painter.drawText(text_box, text_flags, text) + painter.restore() + + def paint(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + health = data_item[u'health'] + + # ---------------- + # |b---b| | + # |b|i|b| 0S 0L | + # |b---b| | + # ---------------- + + r = rect + + # Indicator ellipse rectangle + y = r.top() + (r.height() - self.indicator_side) / 2 + x = r.left() + self.indicator_border + w = self.indicator_side + h = self.indicator_side + indicator_rect = QRect(x, y, w, h) + + # Paint indicator + painter.save() + painter.setBrush(QBrush(self.health_colors[health])) + painter.setPen(QPen(QColor(Qt.darkGray), 0, Qt.SolidLine, Qt.RoundCap)) + painter.drawEllipse(indicator_rect) + painter.restore() + + x = indicator_rect.left() + indicator_rect.width() + 2 * self.indicator_border + y = r.top() + w = r.width() - indicator_rect.width() - 2 * self.indicator_border + h = r.height() + text_box = QRect(x, y, w, h) + + # Paint status text, if necessary + if health in [HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_ERROR]: + self.draw_text(painter, text_box, health) + else: + seeders = int(data_item[u'num_seeders']) + leechers = int(data_item[u'num_leechers']) + + txt = u'S' + str(seeders) + u' L' + str(leechers) + + self.draw_text(painter, text_box, txt) diff --git a/TriblerGUI/widgets/leftmenuplaylist.py b/TriblerGUI/widgets/leftmenuplaylist.py index 159bc08f102..f7b817eb6ac 100644 --- a/TriblerGUI/widgets/leftmenuplaylist.py +++ b/TriblerGUI/widgets/leftmenuplaylist.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import from PyQt5.QtCore import QTimer from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QListWidget diff --git a/TriblerGUI/widgets/manageplaylistpage.py b/TriblerGUI/widgets/manageplaylistpage.py deleted file mode 100644 index 68b8d31a353..00000000000 --- a/TriblerGUI/widgets/manageplaylistpage.py +++ /dev/null @@ -1,154 +0,0 @@ -from PyQt5.QtCore import Qt, pyqtSignal -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget, QListWidgetItem -from TriblerGUI.defs import PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS -from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.utilities import get_image_path - - -class ManagePlaylistPage(QWidget): - """ - On this page, users can add or remove torrents from/to a playlist. - """ - - playlist_saved = pyqtSignal() - - def __init__(self): - QWidget.__init__(self) - - self.channel_info = None - self.playlist_info = None - self.request_mgr = None - - self.torrents_in_playlist = [] - self.torrents_in_channel = [] - - self.torrents_to_create = [] - self.torrents_to_remove = [] - - self.pending_requests = [] - self.requests_done = 0 - - def initialize(self, channel_info, playlist_info): - self.channel_info = channel_info - self.playlist_info = playlist_info - self.window().edit_channel_details_manage_playlist_header.setText("Manage torrents in playlist '%s'" % - playlist_info['name']) - self.window().manage_channel_playlist_torrents_back.setIcon(QIcon(get_image_path('page_back.png'))) - - self.window().playlist_manage_add_to_playlist.clicked.connect(self.on_add_clicked) - self.window().playlist_manage_remove_from_playlist.clicked.connect(self.on_remove_clicked) - self.window().edit_channel_manage_playlist_save_button.clicked.connect(self.on_save_clicked) - self.window().manage_channel_playlist_torrents_back.clicked.connect(self.on_playlist_manage_back_clicked) - - # Load torrents in your channel - self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("channels/discovered/%s/torrents?disable_filter=1" % - channel_info["identifier"], self.on_received_channel_torrents) - - self.torrents_in_playlist = [] - self.torrents_in_channel = [] - - self.torrents_to_create = [] - self.torrents_to_remove = [] - - self.pending_requests = [] - self.requests_done = 0 - - def on_playlist_manage_back_clicked(self): - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS) - - def update_lists(self): - self.window().playlist_manage_in_channel_list.clear() - self.window().playlist_manage_in_playlist_list.clear() - - for torrent in self.torrents_in_channel: - item = QListWidgetItem(torrent["name"], self.window().playlist_manage_in_channel_list) - item.setData(Qt.UserRole, torrent) - self.window().playlist_manage_in_channel_list.addItem(item) - - for torrent in self.torrents_in_playlist: - item = QListWidgetItem(torrent["name"], self.window().playlist_manage_in_playlist_list) - item.setData(Qt.UserRole, torrent) - self.window().playlist_manage_in_playlist_list.addItem(item) - - @staticmethod - def remove_torrent_from_list(torrent, remove_from_list): - index = -1 - for torrent_index in xrange(len(remove_from_list)): - if remove_from_list[torrent_index]['infohash'] == torrent['infohash']: - index = torrent_index - break - - if index != -1: - del remove_from_list[index] - - def on_received_channel_torrents(self, result): - if not result: - return - self.torrents_in_playlist = self.playlist_info['torrents'] - - self.torrents_in_channel = [] - for torrent in result['torrents']: - if not ManagePlaylistPage.list_contains_torrent(self.torrents_in_playlist, torrent): - self.torrents_in_channel.append(torrent) - - self.update_lists() - - @staticmethod - def list_contains_torrent(torrent_list, torrent): - for playlist_torrent in torrent_list: - if torrent['infohash'] == playlist_torrent['infohash']: - return True - return False - - def on_add_clicked(self): - for item in self.window().playlist_manage_in_channel_list.selectedItems(): - torrent = item.data(Qt.UserRole) - ManagePlaylistPage.remove_torrent_from_list(torrent, self.torrents_in_channel) - self.torrents_in_playlist.append(torrent) - - if ManagePlaylistPage.list_contains_torrent(self.torrents_to_remove, torrent): - ManagePlaylistPage.remove_torrent_from_list(torrent, self.torrents_to_remove) - self.torrents_to_create.append(torrent) - - self.update_lists() - - def on_remove_clicked(self): - for item in self.window().playlist_manage_in_playlist_list.selectedItems(): - torrent = item.data(Qt.UserRole) - ManagePlaylistPage.remove_torrent_from_list(torrent, self.torrents_in_playlist) - self.torrents_in_channel.append(torrent) - - if ManagePlaylistPage.list_contains_torrent(self.torrents_to_create, torrent): - ManagePlaylistPage.remove_torrent_from_list(torrent, self.torrents_to_create) - self.torrents_to_remove.append(torrent) - - self.update_lists() - - def on_save_clicked(self): - self.requests_done = 0 - self.pending_requests = [] - for torrent in self.torrents_to_create: - request = TriblerRequestManager() - request.perform_request("channels/discovered/%s/playlists/%s/%s" % - (self.channel_info["identifier"], self.playlist_info['id'], - torrent['infohash']), self.on_request_done, method="PUT") - self.pending_requests.append(request) - for torrent in self.torrents_to_remove: - request = TriblerRequestManager() - request.perform_request("channels/discovered/%s/playlists/%s/%s" % - (self.channel_info["identifier"], self.playlist_info['id'], torrent['infohash']), - self.on_request_done, method="DELETE") - self.pending_requests.append(request) - - def on_request_done(self, result): - if not result: - return - self.requests_done += 1 - if self.requests_done == len(self.pending_requests): - self.on_requests_done() - - def on_requests_done(self): - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_PLAYLIST_TORRENTS) - self.playlist_saved.emit() diff --git a/TriblerGUI/widgets/playlist_list_item.py b/TriblerGUI/widgets/playlist_list_item.py deleted file mode 100644 index 4cce3950e82..00000000000 --- a/TriblerGUI/widgets/playlist_list_item.py +++ /dev/null @@ -1,45 +0,0 @@ -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget - -from TriblerGUI.tribler_window import fc_playlist_list_item -from TriblerGUI.utilities import get_image_path - - -class PlaylistListItem(QWidget, fc_playlist_list_item): - """ - This class is responsible for managing the playlist item widget. - """ - - def __init__(self, parent, playlist, show_controls=False, on_remove_clicked=None, on_edit_clicked=None): - QWidget.__init__(self, parent) - fc_playlist_list_item.__init__(self) - - self.setupUi(self) - - self.playlist_info = playlist - - self.edit_playlist_button.setIcon(QIcon(get_image_path("edit_white.png"))) - self.remove_playlist_button.setIcon(QIcon(get_image_path("delete.png"))) - - self.playlist_name.setText(playlist["name"]) - self.playlist_num_items.setText("%d items" % len(playlist["torrents"])) - - self.thumbnail_widget.initialize(playlist["name"], 24) - - self.controls_container.setHidden(True) - self.show_controls = show_controls - - if on_remove_clicked is not None: - self.remove_playlist_button.clicked.connect(lambda: on_remove_clicked(self)) - - if on_edit_clicked is not None: - self.edit_playlist_button.clicked.connect(lambda: on_edit_clicked(self)) - - def enterEvent(self, _): - if self.show_controls: - self.controls_container.setHidden(False) - self.edit_playlist_button.setIcon(QIcon(get_image_path('edit_white.png'))) - self.remove_playlist_button.setIcon(QIcon(get_image_path('delete.png'))) - - def leaveEvent(self, _): - self.controls_container.setHidden(True) diff --git a/TriblerGUI/widgets/playlistpage.py b/TriblerGUI/widgets/playlistpage.py deleted file mode 100644 index 4d2eaafa946..00000000000 --- a/TriblerGUI/widgets/playlistpage.py +++ /dev/null @@ -1,26 +0,0 @@ -from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget - -from TriblerGUI.widgets.channel_torrent_list_item import ChannelTorrentListItem -from TriblerGUI.utilities import get_image_path - - -class PlaylistPage(QWidget): - """ - This page shows torrents inside a specific playlist. - """ - - def __init__(self): - QWidget.__init__(self) - self.playlist = None - - def initialize_with_playlist(self, playlist): - self.playlist = playlist - self.window().playlist_name_label.setText(playlist["name"]) - self.window().playlist_num_items_label.setText("%d items" % len(playlist["torrents"])) - self.window().playlist_back_button.setIcon(QIcon(get_image_path('page_back.png'))) - - items = [] - for result in playlist['torrents']: - items.append((ChannelTorrentListItem, result)) - self.window().playlist_torrents_list.set_data_items(items) diff --git a/TriblerGUI/widgets/searchresultspage.py b/TriblerGUI/widgets/searchresultspage.py index 6e18fcf3c91..3c255a85d12 100644 --- a/TriblerGUI/widgets/searchresultspage.py +++ b/TriblerGUI/widgets/searchresultspage.py @@ -1,9 +1,9 @@ -from PyQt5.QtCore import QTimer +from __future__ import absolute_import + from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.channel_list_item import ChannelListItem -from TriblerGUI.widgets.channel_torrent_list_item import ChannelTorrentListItem -from TriblerGUI.utilities import bisect_right +from TriblerGUI.widgets.channelview import ChannelContentsModel +from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS class SearchResultsPage(QWidget): @@ -17,22 +17,34 @@ def __init__(self): self.health_timer = None self.show_torrents = True self.show_channels = True + self.query = None + self.model_mixed = None + self.model_channels = None + self.model_torrents = None + # TODO: use currentIndex from tab widget instead + self.tab_state = 'all' def initialize_search_results_page(self): self.window().search_results_tab.initialize() self.window().search_results_tab.clicked_tab_button.connect(self.clicked_tab_button) - self.window().search_torrents_detail_widget.hide() + self.window().search_page_container.channel_entry_clicked.connect(self.window().on_channel_clicked) def perform_search(self, query): + self.query = query + + self.model_mixed = ChannelContentsModel(parent=None, search_query=query) + self.model_channels = ChannelContentsModel(parent=None, search_query=query, search_type=u'channel') + self.model_torrents = ChannelContentsModel(parent=None, search_query=query, search_type=u'torrent') + self.switch_model() + self.search_results = {'channels': [], 'torrents': []} self.window().num_search_results_label.setText("") trimmed_query = query if len(query) < 50 else "%s..." % query[:50] self.window().search_results_header_label.setText("Search results for: %s" % trimmed_query) - self.window().search_results_list.set_data_items([]) # To clean the list - self.window().search_results_tab.on_tab_button_click(self.window().search_results_all_button) # Start the health timer that checks the health of the first five results + """ if self.health_timer: self.health_timer.stop() @@ -40,92 +52,50 @@ def perform_search(self, query): self.health_timer.setSingleShot(True) self.health_timer.timeout.connect(self.check_health_of_results) self.health_timer.start(2000) - def check_health_of_results(self): first_torrents = self.window().search_results_list.get_first_items(5, cls=ChannelTorrentListItem) for torrent_item in first_torrents: torrent_item.check_health() + """ + + def set_columns_visibility(self, column_names, hide=True): + for column_name in column_names: + self.window().search_page_container.torrents_table.setColumnHidden( + self.model_torrents.column_position[column_name], not hide) + + def switch_model(self): + # Hide all columns that are hidden by at least one view + self.window().search_page_container.buttons_container.setHidden(True) + self.window().search_page_container.top_bar_container.setHidden(True) + + if self.tab_state == 'all': + self.window().search_page_container.set_model(self.model_mixed) + self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) + self.set_columns_visibility([u'subscribed', u'health', ACTION_BUTTONS], True) + self.window().search_page_container.details_tab_widget.setHidden(False) + + elif self.tab_state == 'channels': + self.window().search_page_container.set_model(self.model_channels) + self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) + self.set_columns_visibility([u'subscribed'], True) + self.window().search_page_container.details_tab_widget.setHidden(True) + + elif self.tab_state == 'torrents': + self.window().search_page_container.set_model(self.model_torrents) + self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) + self.set_columns_visibility([u'health', ACTION_BUTTONS], True) + self.window().search_page_container.details_tab_widget.setHidden(False) def clicked_tab_button(self, tab_button_name): if tab_button_name == "search_results_all_button": - self.show_torrents = True - self.show_channels = True - self.load_search_results_in_list() + self.tab_state = 'all' elif tab_button_name == "search_results_channels_button": - self.show_torrents = False - self.show_channels = True - self.load_search_results_in_list() + self.tab_state = 'channels' elif tab_button_name == "search_results_torrents_button": - self.show_torrents = True - self.show_channels = False - self.load_search_results_in_list() + self.tab_state = 'torrents' + self.switch_model() def update_num_search_results(self): self.window().num_search_results_label.setText("%d results" % (len(self.search_results['channels']) + len(self.search_results['torrents']))) - - def clicked_item(self): - if len(self.window().search_results_list.selectedItems()) != 1: - self.window().search_torrents_detail_widget.hide() - else: - item = self.window().search_results_list.selectedItems()[0] - list_widget = item.listWidget() - list_item = list_widget.itemWidget(item) - if isinstance(list_item, ChannelTorrentListItem): - self.window().search_torrents_detail_widget.update_with_torrent(list_item.torrent_info) - self.window().search_torrents_detail_widget.show() - else: - self.window().search_torrents_detail_widget.hide() - - def load_search_results_in_list(self): - all_items = [] - if self.show_channels: - for channel_item in self.search_results['channels']: - all_items.append((ChannelListItem, channel_item)) - - if self.show_torrents: - self.search_results['torrents'] = sorted(self.search_results['torrents'], - key=lambda item: item['relevance_score'], - reverse=True) - for torrent_item in self.search_results['torrents']: - all_items.append((ChannelTorrentListItem, torrent_item)) - - self.window().search_results_list.set_data_items(all_items) - - def received_search_result_channel(self, result): - # Ignore channels that have a small amount of torrents or have no votes - if result['torrents'] <= 2 or result['votes'] == 0: - return - if self.is_duplicate_channel(result): - return - channel_index = bisect_right(result, self.search_results['channels'], is_torrent=False) - if self.show_channels: - self.window().search_results_list.insert_item(channel_index, (ChannelListItem, result)) - - self.search_results['channels'].insert(channel_index, result) - self.update_num_search_results() - - def received_search_result_torrent(self, result): - if self.is_duplicate_torrent(result): - return - torrent_index = bisect_right(result, self.search_results['torrents'], is_torrent=True) - num_channels_visible = len(self.search_results['channels']) if self.show_channels else 0 - if self.show_torrents: - self.window().search_results_list.insert_item( - torrent_index + num_channels_visible, (ChannelTorrentListItem, result)) - - self.search_results['torrents'].insert(torrent_index, result) - self.update_num_search_results() - - def is_duplicate_channel(self, result): - for channel_item in self.search_results['channels']: - if result[u'dispersy_cid'] == channel_item[u'dispersy_cid']: - return True - return False - - def is_duplicate_torrent(self, result): - for torrent_item in self.search_results['torrents']: - if result[u'infohash'] == torrent_item[u'infohash']: - return True - return False diff --git a/TriblerGUI/widgets/subscribedchannelspage.py b/TriblerGUI/widgets/subscribedchannelspage.py index 432cb6a53e4..bfb449738f8 100644 --- a/TriblerGUI/widgets/subscribedchannelspage.py +++ b/TriblerGUI/widgets/subscribedchannelspage.py @@ -1,10 +1,10 @@ +from __future__ import absolute_import from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.channel_list_item import ChannelListItem from TriblerGUI.defs import BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog -from TriblerGUI.widgets.loading_list_item import LoadingListItem from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS class SubscribedChannelsPage(QWidget): @@ -21,27 +21,21 @@ def __init__(self): def initialize(self): self.window().add_subscription_button.clicked.connect(self.on_add_subscription_clicked) - def load_subscribed_channels(self): - self.window().subscribed_channels_list.set_data_items([(LoadingListItem, None)]) - - self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("channels/subscribed", self.received_subscribed_channels) - def received_subscribed_channels(self, results): - if not results: - return - self.window().subscribed_channels_list.set_data_items([]) - items = [] + container = self.window().subscribed_channels_container + container.initialize_model(subscribed=True) + container.channel_entry_clicked.connect(self.window().on_channel_clicked) + container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) + container.torrents_table.setColumnHidden(container.model.column_position[u'health'], True) + container.torrents_table.setColumnHidden(container.model.column_position[ACTION_BUTTONS], True) + container.buttons_container.setHidden(True) + container.top_bar_container.setHidden(True) + container.details_tab_widget.setHidden(True) - if len(results['subscribed']) == 0: - self.window().subscribed_channels_list.set_data_items( - [(LoadingListItem, "You are not subscribed to any channel.")]) - return - - for result in results['subscribed']: - items.append((ChannelListItem, result)) - self.window().subscribed_channels_list.set_data_items(items) + def load_subscribed_channels(self): + self.window().subscribed_channels_container.model.refresh() + #FIXME: GigaChannel def on_add_subscription_clicked(self): self.dialog = ConfirmationDialog(self, "Add subscribed channel", "Please enter the identifier of the channel you want to subscribe to below. " @@ -53,6 +47,7 @@ def on_add_subscription_clicked(self): self.dialog.button_clicked.connect(self.on_subscription_added) self.dialog.show() + #FIXME: GigaChannel def on_subscription_added(self, action): if action == 0: self.request_mgr = TriblerRequestManager() diff --git a/TriblerGUI/widgets/subscriptionswidget.py b/TriblerGUI/widgets/subscriptionswidget.py index 57e294711f8..59a71a94a88 100644 --- a/TriblerGUI/widgets/subscriptionswidget.py +++ b/TriblerGUI/widgets/subscriptionswidget.py @@ -46,7 +46,7 @@ def update_subscribe_button(self, remote_response=None): if remote_response and 'votes' in remote_response: self.channel_info["votes"] = remote_response['votes'] - if self.channel_info["subscribed"]: + if int(self.channel_info["subscribed"]): self.subscribe_button.setIcon(QIcon(QPixmap(get_image_path('subscribed_yes.png')))) else: self.subscribe_button.setIcon(QIcon(QPixmap(get_image_path('subscribed_not.png')))) @@ -61,10 +61,12 @@ def update_subscribe_button(self, remote_response=None): self.credit_mining_button.setIcon(QIcon(QPixmap(get_image_path('credit_mining_not.png')))) else: self.credit_mining_button.hide() + # Hide credit mining button until everything else works + self.credit_mining_button.hide() def on_subscribe_button_click(self): self.request_mgr = TriblerRequestManager() - if self.channel_info["subscribed"]: + if int(self.channel_info["subscribed"]): self.request_mgr.perform_request("channels/subscribed/%s" % self.channel_info['dispersy_cid'], self.on_channel_unsubscribed, method='DELETE') diff --git a/TriblerGUI/widgets/torrentdetailstabwidget.py b/TriblerGUI/widgets/torrentdetailstabwidget.py index dcbe9239f51..64edaf5f301 100644 --- a/TriblerGUI/widgets/torrentdetailstabwidget.py +++ b/TriblerGUI/widgets/torrentdetailstabwidget.py @@ -1,22 +1,25 @@ +from __future__ import absolute_import import logging -import time +from PyQt5.QtCore import pyqtSignal from PyQt5.QtWidgets import QLabel from PyQt5.QtWidgets import QTabWidget from PyQt5.QtWidgets import QTreeWidget from PyQt5.QtWidgets import QTreeWidgetItem +from Tribler.Core.Modules.restapi.util import HEALTH_CHECKING from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.utilities import format_size from TriblerGUI.widgets.ellipsebutton import EllipseButton - class TorrentDetailsTabWidget(QTabWidget): + health_check_clicked = pyqtSignal(dict) """ The TorrentDetailsTabWidget is the tab that provides details about a specific selected torrent. This information includes the generic info about the torrent, files and trackers. """ + #TODO: rewrite this as a view into ChannelContentsModel def __init__(self, parent): QTabWidget.__init__(self, parent) self.torrent_info = None @@ -75,12 +78,13 @@ def on_torrent_info(self, torrent_info): torrent_info["num_leechers"])) elif torrent_info["num_leechers"] > 0: self.torrent_detail_health_label.setText("unknown health (found peers)") + elif self.is_health_checking or (u'health' in torrent_info and torrent_info[u'health'] == HEALTH_CHECKING): + self.torrent_detail_health_label.setText("Checking...") else: self.torrent_detail_health_label.setText("no peers found") def update_with_torrent(self, torrent_info): self.torrent_info = torrent_info - self.is_health_checking = False self.torrent_detail_name_label.setText(self.torrent_info["name"]) if self.torrent_info["category"]: self.torrent_detail_category_label.setText(self.torrent_info["category"].lower()) @@ -97,6 +101,8 @@ def update_with_torrent(self, torrent_info): self.torrent_info["num_leechers"])) elif self.torrent_info["num_leechers"] > 0: self.torrent_detail_health_label.setText("unknown health (found peers)") + elif self.is_health_checking or (u'health' in torrent_info and torrent_info[u'health'] == HEALTH_CHECKING): + self.torrent_detail_health_label.setText("Checking...") else: self.torrent_detail_health_label.setText("no peers found") @@ -108,44 +114,17 @@ def update_with_torrent(self, torrent_info): self.request_mgr.perform_request("torrents/%s" % self.torrent_info["infohash"], self.on_torrent_info) def on_check_health_clicked(self, timeout=15): - if self.is_health_checking and (time.time() - self.last_health_check_ts < timeout): - return - - self.is_health_checking = True - self.torrent_detail_health_label.setText("Checking...") - self.last_health_check_ts = time.time() - self.health_request_mgr = TriblerRequestManager() - self.health_request_mgr.perform_request("torrents/%s/health?timeout=%s&refresh=%d" % - (self.torrent_info["infohash"], timeout, 1), - self.on_health_response, capture_errors=False, priority="LOW", - on_cancel=self.on_cancel_health_check) - - def on_health_response(self, response): - if not response: - return - total_seeders = 0 - total_leechers = 0 - - if not response or 'error' in response: - self.update_health(0, 0) # Just set the health to 0 seeders, 0 leechers - return + self.health_check_clicked.emit(self.torrent_info) - for _, status in response['health'].iteritems(): - if 'error' in status: - continue # Timeout or invalid status - total_seeders += int(status['seeders']) - total_leechers += int(status['leechers']) - - self.is_health_checking = False - self.update_health(total_seeders, total_leechers) - - def update_health(self, seeders, leechers): + def update_health(self, seeders, leechers, health=None): try: if seeders > 0: self.torrent_detail_health_label.setText("good health (S%d L%d)" % (seeders, leechers)) elif leechers > 0: self.torrent_detail_health_label.setText("unknown health (found peers)") + elif health == HEALTH_CHECKING: + self.torrent_detail_health_label.setText("Checking...") else: self.torrent_detail_health_label.setText("no peers found") except RuntimeError: @@ -153,3 +132,15 @@ def update_health(self, seeders, leechers): def on_cancel_health_check(self): self.is_health_checking = False + + def update_from_model(self, i1, i2, role): + if not self.torrent_info: + return + + # We only react to very specific update type that was generated by our actions + if i1.row() == i2.row(): + torrent_info = i1.model().data_items[i1.row()] + if self.torrent_info[u'infohash'] == torrent_info[u'infohash']: + self.is_health_checking = torrent_info[u'health'] == HEALTH_CHECKING + self.update_health(torrent_info[u'num_seeders'], torrent_info[u'num_leechers'], + health=torrent_info[u'health']) diff --git a/gen_db.py b/gen_db.py new file mode 100644 index 00000000000..e69de29bb2d From e914a939c95df22376dd6d283c089b520786a014 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 2 Jan 2019 14:49:43 +0100 Subject: [PATCH 02/38] add lz4, fix timestamp resolution problem This commit adds support for lz4 compression of mdblobs. Squashed mdblobs are now compressed by default, both on disk and when sent through the gossip community. In addition, this commit fixes the problem where metadata timestamp was incorrectly coerced to float from double on serialization. To simplify updating the tests on metdata format change, a simple script to regenerate tests is added. --- Tribler/Core/CacheDB/SqliteCacheDBHandler.py | 1 + Tribler/Core/Libtorrent/LibtorrentMgr.py | 1 + .../OrmBindings/channel_metadata.py | 78 +++++------ .../MetadataStore/OrmBindings/metadata.py | 26 ++-- .../OrmBindings/torrent_metadata.py | 12 +- .../Modules/MetadataStore/serialization.py | 131 +++++++++--------- Tribler/Core/Modules/MetadataStore/store.py | 61 ++++++-- Tribler/Core/Modules/restapi/util.py | 8 +- Tribler/Core/Upgrade/db71_to_pony.py | 39 ++++++ .../Modules/MetadataStore/gen_test_data.py | 65 +++++++++ .../MetadataStore/test_channel_download.py | 4 +- .../MetadataStore/test_channel_metadata.py | 24 ++-- .../Modules/MetadataStore/test_serialize.py | 14 +- .../Core/Modules/MetadataStore/test_store.py | 27 ++-- .../000000000003.mdblob.lz4 | Bin 0 -> 283 bytes .../000000000008.mdblob.lz4 | Bin 0 -> 537 bytes .../000000000010.mdblob.lz4 | Bin 0 -> 219 bytes .../000000000013.mdblob.lz4 | Bin 0 -> 224 bytes .../000000000001.mdblob | Bin 265 -> 0 bytes .../000000000003.mdblob | Bin 561 -> 0 bytes .../000000000004.mdblob | Bin 218 -> 0 bytes .../000000000005.mdblob | Bin 300 -> 0 bytes .../000000000006.mdblob | Bin 298 -> 0 bytes .../Core/data/sample_channel/channel.mdblob | Bin 230 -> 228 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 230 -> 228 bytes .../data/sample_channel/channel_upd.torrent | 2 +- Tribler/community/gigachannel/community.py | 2 +- Tribler/dispersy | 2 +- TriblerGUI/tribler_app.py | 3 +- TriblerGUI/widgets/channelview.py | 4 +- logger.conf | 10 +- run_tribler.py | 11 +- 33 files changed, 346 insertions(+), 181 deletions(-) create mode 100644 Tribler/Core/Upgrade/db71_to_pony.py create mode 100644 Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py create mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000004.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob delete mode 100644 Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob diff --git a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py b/Tribler/Core/CacheDB/SqliteCacheDBHandler.py index 073d35d5134..90cfba05e6b 100644 --- a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py +++ b/Tribler/Core/CacheDB/SqliteCacheDBHandler.py @@ -1,3 +1,4 @@ +from __future__ import absolute_import """ SqlitecacheDBHanler. diff --git a/Tribler/Core/Libtorrent/LibtorrentMgr.py b/Tribler/Core/Libtorrent/LibtorrentMgr.py index 80a5519b55f..459cdd81701 100644 --- a/Tribler/Core/Libtorrent/LibtorrentMgr.py +++ b/Tribler/Core/Libtorrent/LibtorrentMgr.py @@ -151,6 +151,7 @@ def create_session(self, hops=0, store_listen_port=True): # the settings dictionary settings['outgoing_port'] = 0 settings['num_outgoing_ports'] = 1 + settings['allow_multiple_connections_per_ip'] = 1 # Copy construct so we don't modify the default list extensions = list(DEFAULT_LT_EXTENSIONS) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 3b5292762b0..28247113dc8 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -4,6 +4,7 @@ from datetime import datetime from libtorrent import file_storage, add_files, create_torrent, set_piece_hashes, bencode, torrent_info +import lz4.frame from pony import orm from pony.orm import db_session, raw_sql, select @@ -12,8 +13,9 @@ from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelNameError from Tribler.pyipv8.ipv8.database import database_blob -CHANNEL_DIR_NAME_LENGTH = 60 # Its not 40 so it could be distinguished from infohash +CHANNEL_DIR_NAME_LENGTH = 32 # Its not 40 so it could be distinguished from infohash BLOB_EXTENSION = '.mdblob' +LZ4_END_MARK_SIZE = 4 # in bytes, from original specification. We don't use CRC def create_torrent_from_dir(directory, torrent_filename): @@ -41,19 +43,23 @@ def entries_to_chunk(metadata_list, chunk_size, start_index=0): """ # Try to fit as many blobs into this chunk as permitted by chunk_size and # calculate their ends' offsets in the blob - out_list = [] - offset = 0 last_entry_index = None - for index, metadata in enumerate(metadata_list[start_index:], start_index): - blob = ''.join(metadata.serialized_delete() if metadata.status == TODELETE else metadata.serialized()) - # Chunk size limit reached? - if offset + len(blob) > chunk_size: - break - # Now that we now it will fit in, we can safely append it - offset += len(blob) - last_entry_index = index - out_list.append(blob) + with lz4.frame.LZ4FrameCompressor(auto_flush=True) as c: + header = c.begin() + offset = len(header) + out_list = [header] # LZ4 header + for index, metadata in enumerate(metadata_list[start_index:], start_index): + blob = c.compress( + ''.join(metadata.serialized_delete() if metadata.status == TODELETE else metadata.serialized())) + # Chunk size limit reached? + if offset + len(blob) > (chunk_size - LZ4_END_MARK_SIZE): + break + # Now that we now it will fit in, we can safely append it + offset += len(blob) + last_entry_index = index + out_list.append(blob) + out_list.append(c.flush()) # LZ4 end mark chunk = ''.join(out_list) if last_entry_index is None: @@ -65,10 +71,15 @@ def entries_to_chunk(metadata_list, chunk_size, start_index=0): def define_binding(db): class ChannelMetadata(db.TorrentMetadata): _discriminator_ = CHANNEL_TORRENT - version = orm.Optional(int, size=64, default=0) + + # Serializable + num_entries = orm.Optional(int, size=64, default=0) + + # Local subscribed = orm.Optional(bool, default=False) votes = orm.Optional(int, size=64, default=0) local_version = orm.Optional(int, size=64, default=0) + _payload_class = ChannelMetadataPayload _channels_dir = None _category_filter = None @@ -76,12 +87,10 @@ class ChannelMetadata(db.TorrentMetadata): @db_session def update_metadata(self, update_dict=None): - now = datetime.utcnow() channel_dict = self.to_dict() channel_dict.update(update_dict or {}) channel_dict.update({ "size": self.contents_len, - "timestamp": now, }) self.set(**channel_dict) self.sign() @@ -98,14 +107,14 @@ def process_channel_metadata_payload(cls, payload): if not channel: return ChannelMetadata.from_payload(payload) - if payload.version > channel.version: + if payload.timestamp > channel.timestamp: channel.set(**payload.to_dict()) return channel @classmethod @db_session def get_my_channel(cls): - return ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()) + return ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()[10:]) @classmethod @db_session @@ -116,10 +125,10 @@ def create_channel(cls, title, description): :param description: The description of the channel :return: The channel metadata """ - if ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()): + if ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()[10:]): raise DuplicateChannelNameError() - my_channel = cls(public_key=database_blob(cls._my_key.pub().key_to_bin()), title=title, + my_channel = cls(public_key=database_blob(cls._my_key.pub().key_to_bin()[10:]), title=title, tags=description, subscribed=True) my_channel.sign() return my_channel @@ -137,7 +146,7 @@ def consolidate_channel_torrent(self): for filename in os.listdir(folder): file_path = os.path.join(folder, filename) # We only remove mdblobs and leave the rest as it is - if filename.endswith(BLOB_EXTENSION): + if filename.endswith(BLOB_EXTENSION) or filename.endswith(BLOB_EXTENSION + '.lz4'): os.unlink(file_path) for g in self.contents: g.status = NEW @@ -155,33 +164,21 @@ def update_channel_torrent(self, metadata_list): if not os.path.isdir(channel_dir): os.makedirs(channel_dir) - # Basically, a channel's version number is the size of the set of all unique entries that were ever put - # into the channel. For a channel that never had anything deleted, version = len(contents) - old_version = self.version index = 0 while index < len(metadata_list): # Squash several serialized and signed metadata entries into a single file data, index = entries_to_chunk(metadata_list, self._CHUNK_SIZE_LIMIT, start_index=index) - blob_filename = str(old_version + index).zfill(12) + BLOB_EXTENSION + blob_filename = str(self._clock.tick()).zfill(12) + BLOB_EXTENSION + '.lz4' with open(os.path.join(channel_dir, blob_filename), 'wb') as f: f.write(data) - new_version = self.version + len(metadata_list) - # Make torrent out of dir with metadata files - start_ts = datetime.utcnow() torrent, infohash = create_torrent_from_dir(channel_dir, os.path.join(self._channels_dir, self.dir_name + ".torrent")) - - # Torrent files have time resolution of 1 second. If a channel torrent is created in the same second as - # a new metadata entry, the latter would still be listened as a staged entry. To account for this, - # we store torrent_date with higher resolution. As libtorrent uses the moment of beginning of the torrent - # creation as a source for 'creation date' for torrent, we sample it just before calling it. Then we select - # the larger of the two timestamps. torrent_date = datetime.utcfromtimestamp(torrent['creation date']) - torrent_date_corrected = start_ts if start_ts > torrent_date else torrent_date - return {"infohash": infohash, "version": new_version, "torrent_date": torrent_date_corrected} + return {"infohash": infohash, "num_entries": self.contents_len, + "timestamp": self._clock.tick(), "torrent_date": torrent_date} def commit_channel_torrent(self): """ @@ -199,7 +196,7 @@ def commit_channel_torrent(self): str(self.public_key).encode("hex")) else: self.update_metadata(update_dict) - self.local_version = self.version + self.local_version = self.timestamp # Change status of committed metadata and clean up obsolete TODELETE entries for g in md_list: if g.status == NEW: @@ -208,11 +205,10 @@ def commit_channel_torrent(self): g.delete() # Write the channel mdblob to disk - with open(os.path.join(self._channels_dir, self.dir_name + BLOB_EXTENSION), 'wb') as out_file: - out_file.write(''.join(self.serialized())) + self.to_file(os.path.join(self._channels_dir, self.dir_name + BLOB_EXTENSION)) self._logger.info("Channel %s committed with %i new entries. New version is %i", - str(self.public_key).encode("hex"), len(md_list), update_dict['version']) + str(self.public_key).encode("hex"), len(md_list), update_dict['timestamp']) return new_infohash @db_session @@ -250,7 +246,7 @@ def add_torrent_to_channel(self, tdef, extra_info): "tracker_info": tdef.get_tracker() or '', "status": NEW }) - torrent_metadata.sign() + return torrent_metadata @property def dirty(self): @@ -388,6 +384,6 @@ def remove_contents(self): @classmethod @db_session def get_updated_channels(cls): - return select(g for g in cls if g.subscribed and (g.local_version < g.version)) + return select(g for g in cls if g.subscribed and (g.local_version < g.timestamp)) return ChannelMetadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index 05000986b9b..fa12af26864 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -18,31 +18,39 @@ UPDATE_AVAILABLE = 4 PREVIEW_UPDATE_AVAILABLE = 5 -PUBLIC_KEY_LEN = 74 +PUBLIC_KEY_LEN = 64 def define_binding(db): class Metadata(db.Entity): - rowid = orm.PrimaryKey(int, auto=True) - metadata_type = orm.Discriminator(int) _discriminator_ = TYPELESS + + # Serializable + metadata_type = orm.Discriminator(int) # We want to make signature unique=True for safety, but can't do it in Python2 because of Pony bug #390 signature = orm.Optional(database_blob) - timestamp = orm.Optional(datetime, default=datetime.utcnow) - tc_pointer = orm.Optional(int, size=64, default=0) + id_ = orm.Optional(int, size=64, default=0) public_key = orm.Optional(database_blob, default='\x00' * PUBLIC_KEY_LEN) + + # Local + rowid = orm.PrimaryKey(int, auto=True) addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) status = orm.Optional(int, default=COMMITTED) + + # Special properties _payload_class = MetadataPayload _my_key = None _logger = None + _clock = None def __init__(self, *args, **kwargs): + if "id_" not in kwargs: + kwargs["id_"] = self._clock.tick() # Special "sign_with" argument given, sign with it private_key_override = None if "sign_with" in kwargs: - kwargs["public_key"] = database_blob(kwargs["sign_with"].pub().key_to_bin()) + kwargs["public_key"] = database_blob(kwargs["sign_with"].pub().key_to_bin()[10:]) private_key_override = kwargs["sign_with"] kwargs.pop("sign_with") @@ -55,7 +63,7 @@ def __init__(self, *args, **kwargs): # No key/signature given, sign with our own key. elif ("signature" not in kwargs) and \ (("public_key" not in kwargs) or ( - kwargs["public_key"] == database_blob(self._my_key.pub().key_to_bin()))): + kwargs["public_key"] == database_blob(self._my_key.pub().key_to_bin()[10:]))): self.sign(self._my_key) return @@ -125,12 +133,12 @@ def to_delete_file(self, filename): def sign(self, key=None): if not key: key = self._my_key - self.public_key = database_blob(key.pub().key_to_bin()) + self.public_key = database_blob(key.pub().key_to_bin()[10:]) _, self.signature = self._serialized(key) def has_valid_signature(self): crypto = default_eccrypto - return (crypto.is_valid_public_bin(str(self.public_key)) + return (crypto.is_valid_public_bin(b"LibNaCLPK:"+str(self.public_key)) and self._payload_class(**self.to_dict()).has_valid_signature()) @classmethod diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index a4475de85d0..9a774a61d4e 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -11,12 +11,20 @@ def define_binding(db): class TorrentMetadata(db.Metadata): _discriminator_ = REGULAR_TORRENT + + # Serializable + + timestamp = orm.Required(int, size=64, default=0) infohash = orm.Optional(database_blob, default='\x00' * 20) - title = orm.Optional(str, default='') size = orm.Optional(int, size=64, default=0) + torrent_date = orm.Optional(datetime, default=datetime.utcnow) + title = orm.Optional(str, default='') tags = orm.Optional(str, default='') tracker_info = orm.Optional(str, default='') - torrent_date = orm.Optional(datetime, default=datetime.utcnow) + + # Local + xxx = orm.Optional(float, default=0) + _payload_class = TorrentMetadataPayload def get_magnet(self): diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index 6f1366b82d3..b47caf7e56a 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -23,40 +23,27 @@ DELETED = 4 -# We have to write our own serialization procedure for timestamps, since -# there is no standard for this, except Unix time, and that is -# deprecated by 2038, that is very soon. - - -def time2float(date_time, epoch=EPOCH): +def time2int(date_time, epoch=EPOCH): """ - Convert a datetime object to a float. + Convert a datetime object to an int . :param date_time: The datetime object to convert. :param epoch: The epoch time, defaults to Jan 1, 1970. - :return: The floating point representation of date_time. + :return: The int representation of date_time. WARNING: TZ-aware timestamps are madhouse... - For Python3 we could use a simpler method: - timestamp = (dt - datetime(1970, 1, 1, tzinfo=timezone.utc)) / timedelta(seconds=1) """ - time_diff = date_time - epoch - return float((time_diff.microseconds + (time_diff.seconds + time_diff.days * 86400) * 10 ** 6) / 10 ** 6) + + return int((date_time - epoch).total_seconds()) -def float2time(timestamp, epoch=EPOCH): +def int2time(timestamp, epoch=EPOCH): """ - Convert a float into a datetime object. + Convert an int into a datetime object. :param timestamp: The timestamp to be converted. :param epoch: The epoch time, defaults to Jan 1, 1970. :return: The datetime representation of timestamp. """ - microseconds_total = int(timestamp * 10 ** 6) - microseconds = microseconds_total % 10 ** 6 - seconds_total = (microseconds_total - microseconds) / 10 ** 6 - seconds = seconds_total % 86400 - days = (seconds_total - seconds) / 86400 - dt = epoch + timedelta(days=days, seconds=seconds, microseconds=microseconds) - return dt + return epoch + timedelta(seconds=timestamp) class KeysMismatchException(Exception): @@ -71,11 +58,11 @@ def read_payload_with_offset(data, offset=0): # First we have to determine the actual payload type metadata_type = struct.unpack_from('>I', database_blob(data), offset=offset)[0] if metadata_type == DELETED: - return DeletedMetadataPayload.from_signed_blob_with_offset(data, check_signature=True, offset=offset) + return DeletedMetadataPayload.from_signed_blob_with_offset(data, offset=offset) elif metadata_type == REGULAR_TORRENT: - return TorrentMetadataPayload.from_signed_blob_with_offset(data, check_signature=True, offset=offset) + return TorrentMetadataPayload.from_signed_blob_with_offset(data, offset=offset) elif metadata_type == CHANNEL_TORRENT: - return ChannelMetadataPayload.from_signed_blob_with_offset(data, check_signature=True, offset=offset) + return ChannelMetadataPayload.from_signed_blob_with_offset(data, offset=offset) # Unknown metadata type, raise exception raise UnknownBlobTypeException @@ -90,31 +77,30 @@ class MetadataPayload(Payload): Payload for metadata. """ - format_list = ['I', '74s', 'f', 'Q'] + format_list = ['I', '64s', 'Q'] - def __init__(self, metadata_type, public_key, timestamp, tc_pointer, **kwargs): + def __init__(self, metadata_type, public_key, id_, **kwargs): super(MetadataPayload, self).__init__() self.metadata_type = metadata_type self.public_key = str(public_key) - self.timestamp = time2float(timestamp) if isinstance(timestamp, datetime) else timestamp - self.tc_pointer = tc_pointer + self.id_ = id_ self.signature = str(kwargs["signature"]) if "signature" in kwargs else EMPTY_SIG def has_valid_signature(self): sig_data = default_serializer.pack_multiple(self.to_pack_list())[0] - return default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(self.public_key), sig_data, - self.signature) + return default_eccrypto.is_valid_signature( + default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + self.public_key), sig_data, + self.signature) def to_pack_list(self): data = [('I', self.metadata_type), - ('74s', self.public_key), - ('f', self.timestamp), - ('Q', self.tc_pointer)] + ('64s', self.public_key), + ('Q', self.id_)] return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, timestamp, tc_pointer): - return MetadataPayload(metadata_type, public_key, timestamp, tc_pointer) + def from_unpack_list(cls, metadata_type, public_key, id_): + return MetadataPayload(metadata_type, public_key, id_) @classmethod def from_signed_blob(cls, data, check_signature=True): @@ -127,7 +113,7 @@ def from_signed_blob_with_offset(cls, data, check_signature=True, offset=0): if check_signature: payload.signature = data[end_offset:end_offset + SIGNATURE_SIZE] data_unsigned = data[offset:end_offset] - key = default_eccrypto.key_from_public_bin(payload.public_key) + key = default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + payload.public_key) if not default_eccrypto.is_valid_signature(key, data_unsigned, payload.signature): raise InvalidSignatureException return payload, end_offset + SIGNATURE_SIZE @@ -136,22 +122,22 @@ def to_dict(self): return { "metadata_type": self.metadata_type, "public_key": self.public_key, - "timestamp": float2time(self.timestamp), - "tc_pointer": self.tc_pointer, + "id_": self.id_, "signature": self.signature } def _serialized(self, key=None): # If we are going to sign it, we must provide a matching key - if key and self.public_key != str(key.pub().key_to_bin()): - raise KeysMismatchException(self.public_key, str(key.pub().key_to_bin())) + if key and self.public_key != str(key.pub().key_to_bin()[10:]): + raise KeysMismatchException(self.public_key, str(key.pub().key_to_bin()[10:])) serialized_data = default_serializer.pack_multiple(self.to_pack_list())[0] if key: signature = default_eccrypto.create_signature(key, serialized_data) # This check ensures that an entry with a wrong signature will not proliferate further - elif default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(self.public_key), serialized_data, + elif default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + self.public_key), + serialized_data, self.signature): signature = self.signature else: @@ -171,37 +157,45 @@ class TorrentMetadataPayload(MetadataPayload): """ Payload for metadata that stores a torrent. """ - format_list = MetadataPayload.format_list + ['20s', 'Q', 'varlenI', 'varlenI', 'varlenI'] + format_list = MetadataPayload.format_list + ['Q', '20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] - def __init__(self, metadata_type, public_key, timestamp, tc_pointer, infohash, size, title, tags, tracker_info, + def __init__(self, metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): - super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, timestamp, tc_pointer, **kwargs) + super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, id_, + **kwargs) + self.timestamp = timestamp self.infohash = str(infohash) self.size = size + self.torrent_date = time2int(torrent_date) if isinstance(torrent_date, datetime) else torrent_date self.title = title.decode('utf-8') if type(title) == str else title self.tags = tags.decode('utf-8') if type(tags) == str else tags self.tracker_info = tracker_info.decode('utf-8') if type(tracker_info) == str else tracker_info def to_pack_list(self): data = super(TorrentMetadataPayload, self).to_pack_list() + data.append(('Q', self.timestamp)) data.append(('20s', self.infohash)) data.append(('Q', self.size)) + data.append(('I', self.torrent_date)) data.append(('varlenI', self.title.encode('utf-8'))) data.append(('varlenI', self.tags.encode('utf-8'))) data.append(('varlenI', self.tracker_info.encode('utf-8'))) return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, timestamp, tc_pointer, infohash, size, title, tags, - tracker_info): - return TorrentMetadataPayload(metadata_type, public_key, timestamp, tc_pointer, infohash, size, title, tags, - tracker_info) + def from_unpack_list(cls, metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info): + return TorrentMetadataPayload(metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info) def to_dict(self): dct = super(TorrentMetadataPayload, self).to_dict() dct.update({ + "timestamp": self.timestamp, "infohash": self.infohash, "size": self.size, + "torrent_date": int2time(self.torrent_date), "title": self.title, "tags": self.tags, "tracker_info": self.tracker_info @@ -221,26 +215,32 @@ class ChannelMetadataPayload(TorrentMetadataPayload): """ format_list = TorrentMetadataPayload.format_list + ['Q'] - def __init__(self, metadata_type, public_key, timestamp, tc_pointer, infohash, size, title, tags, tracker_info, - version, **kwargs): - super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, timestamp, tc_pointer, - infohash, size, title, tags, tracker_info, **kwargs) - self.version = version + def __init__(self, metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info, + num_entries, + **kwargs): + super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, + tracker_info, + **kwargs) + self.num_entries = num_entries def to_pack_list(self): data = super(ChannelMetadataPayload, self).to_pack_list() - data.append(('Q', self.version)) + data.append(('Q', self.num_entries)) return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, timestamp, tc_pointer, infohash, size, title, tags, - tracker_info, version): - return ChannelMetadataPayload(metadata_type, public_key, timestamp, tc_pointer, infohash, size, - title, tags, tracker_info, version) + def from_unpack_list(cls, metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info, + num_entries): + return ChannelMetadataPayload(metadata_type, public_key, id_, + timestamp, infohash, size, torrent_date, title, tags, tracker_info, + num_entries) def to_dict(self): dct = super(ChannelMetadataPayload, self).to_dict() - dct.update({"version": self.version}) + dct.update({"num_entries": self.num_entries}) return dct @@ -250,8 +250,11 @@ class DeletedMetadataPayload(MetadataPayload): """ format_list = MetadataPayload.format_list + ['64s'] - def __init__(self, metadata_type, public_key, timestamp, tc_pointer, delete_signature, **kwargs): - super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, timestamp, tc_pointer, **kwargs) + def __init__(self, metadata_type, public_key, id_, + delete_signature, + **kwargs): + super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, id_, + **kwargs) self.delete_signature = str(delete_signature) def to_pack_list(self): @@ -260,8 +263,10 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, timestamp, tc_pointer, delete_signature): - return DeletedMetadataPayload(metadata_type, public_key, timestamp, tc_pointer, delete_signature) + def from_unpack_list(cls, metadata_type, public_key, id_, + delete_signature): + return DeletedMetadataPayload(metadata_type, public_key, id_, + delete_signature) def to_dict(self): dct = super(DeletedMetadataPayload, self).to_dict() diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 570de8edd15..3ade7d477a5 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -3,6 +3,7 @@ import logging import os +import lz4.frame from pony import orm from pony.orm import db_session @@ -10,12 +11,14 @@ from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ - CHANNEL_TORRENT, DELETED, float2time, ChannelMetadataPayload + CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time # This table should never be used from ORM directly. # It is created as a VIRTUAL table by raw SQL and # maintained by SQL triggers. from Tribler.Core.exceptions import InvalidSignatureException +CLOCK_STATE_FILE = "clock.state" + sql_create_fts_table = """ CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING FTS5 (title, tags, content='Metadata', @@ -50,12 +53,32 @@ class BadChunkException(Exception): pass +class DiscreteClock(object): + # Lamport-clock-like persistent counter + # Horribly inefficient and stupid, but works + def __init__(self, filename=None): + self.filename = filename + self.clock = 0 + # Read the clock from the disk if the filename is given + if self.filename and os.path.isfile(self.filename): + with open(self.filename, 'rb') as f: + self.clock = int(f.read()) + + def tick(self): + self.clock += 1 + if self.filename: + with open(self.filename, 'wb') as f: + f.write(str(self.clock)) + return self.clock + + class MetadataStore(object): def __init__(self, db_filename, channels_dir, my_key): self.db_filename = db_filename self.channels_dir = channels_dir self.my_key = my_key self._logger = logging.getLogger(self.__class__.__name__) + self.clock = DiscreteClock(None if db_filename == ":memory:" else os.path.join(channels_dir, CLOCK_STATE_FILE)) create_db = (db_filename == ":memory:" or not os.path.isfile(self.db_filename)) @@ -69,9 +92,11 @@ def __init__(self, db_filename, channels_dir, my_key): self.TorrentMetadata = torrent_metadata.define_binding(self._db) self.ChannelMetadata = channel_metadata.define_binding(self._db) + self.Metadata._logger = self._logger # Use Store-level logger for every ORM-based class self.Metadata._my_key = my_key + self.Metadata._clock = self.clock + self.ChannelMetadata._channels_dir = channels_dir - self.Metadata._logger = self._logger # Use Store-level logger for every ORM-based class # TODO: move Category Filter into a module-level global stateless object (i.e. make it a singleton) self.ChannelMetadata._category_filter = Category() @@ -104,17 +129,24 @@ def process_channel_dir(self, dirname, channel_id): with db_session: channel = self.ChannelMetadata.get(public_key=channel_id) self._logger.debug("Starting processing channel dir %s. Channel %s local/max version %i/%i", - dirname, str(channel.public_key).encode("hex"), channel.local_version, channel.version) + dirname, str(channel.public_key).encode("hex"), channel.local_version, + channel.timestamp) for filename in sorted(os.listdir(dirname)): with db_session: channel = self.ChannelMetadata.get(public_key=channel_id) full_filename = os.path.join(dirname, filename) + + blob_sequence_number = None if filename.endswith(BLOB_EXTENSION): blob_sequence_number = int(filename[:-len(BLOB_EXTENSION)]) + elif filename.endswith(BLOB_EXTENSION + '.lz4'): + blob_sequence_number = int(filename[:-len(BLOB_EXTENSION + '.lz4')]) + + if blob_sequence_number is not None: # Skip blobs containing data we already have and those that are # ahead of the channel version known to us - if blob_sequence_number <= channel.local_version or blob_sequence_number > channel.version: + if blob_sequence_number <= channel.local_version or blob_sequence_number > channel.timestamp: continue try: self.process_mdblob_file(full_filename) @@ -124,18 +156,27 @@ def process_channel_dir(self, dirname, channel_id): self._logger.error("Not processing metadata located at %s: invalid signature", full_filename) self._logger.debug("Finished processing channel dir %s. Channel %s local/max version %i/%i", - dirname, str(channel.public_key).encode("hex"), channel.local_version, channel.version) + dirname, str(channel.public_key).encode("hex"), channel.local_version, + channel.timestamp) @db_session def process_mdblob_file(self, filepath): """ Process a file with metadata in a channel directory. :param filepath: The path to the file - :return a Metadata object if we can correctly load the metadata + :return Metadata objects list if we can correctly load the metadata """ with open(filepath, 'rb') as f: serialized_data = f.read() - return self.process_squashed_mdblob(serialized_data) + + if filepath.endswith('.lz4'): + return self.process_compressed_mdblob(serialized_data) + else: + return self.process_squashed_mdblob(serialized_data) + + @db_session + def process_compressed_mdblob(self, compressed_data): + return self.process_squashed_mdblob(lz4.frame.decompress(compressed_data)) @db_session def process_squashed_mdblob(self, chunk_data): @@ -176,10 +217,10 @@ def update_channel_info(self, payload): channel = self.ChannelMetadata.get_channel_with_id(payload.public_key) if channel: - if float2time(payload.timestamp) > channel.timestamp: + if payload.timestamp > channel.timestamp: # Update the channel that is already there. self._logger.info("Updating channel metadata %s ts %s->%s", str(channel.public_key).encode("hex"), - str(channel.timestamp), str(float2time(payload.timestamp))) + str(channel.timestamp), str(int2time(payload.timestamp))) channel.set(**ChannelMetadataPayload.to_dict(payload)) else: # Add new channel object to DB @@ -193,4 +234,4 @@ def update_channel_info(self, payload): @db_session def get_my_channel(self): - return self.ChannelMetadata.get_channel_with_id(self.my_key.pub().key_to_bin()) + return self.ChannelMetadata.get_channel_with_id(self.my_key.pub().key_to_bin()[10:]) diff --git a/Tribler/Core/Modules/restapi/util.py b/Tribler/Core/Modules/restapi/util.py index 6469674b0af..6292fb71a19 100644 --- a/Tribler/Core/Modules/restapi/util.py +++ b/Tribler/Core/Modules/restapi/util.py @@ -12,7 +12,7 @@ from twisted.web import http import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.MetadataStore.serialization import time2float, CHANNEL_TORRENT, float2time +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, time2int, int2time from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE HEALTH_CHECKING = u'Checking..' @@ -54,7 +54,7 @@ def convert_channel_metadata_to_tuple(metadata): my_vote = 2 spam = 0 relevance = 0.9 - unix_timestamp = time2float(metadata.timestamp) + unix_timestamp = time2int(metadata.timestamp) return metadata.rowid, str(metadata.public_key), metadata.title, metadata.tags, int(metadata.size), votes, spam, \ my_vote, unix_timestamp, relevance, metadata.status, metadata.torrent_date, metadata.metadata_type @@ -111,7 +111,7 @@ def channel_to_torrent_adapter(channel): 0, 0, 0, - float2time(0), + int2time(0), CHANNEL_TORRENT, hexlify(channel[1]), int(channel[7] == VOTE_SUBSCRIBE)) @@ -142,7 +142,7 @@ def convert_db_torrent_to_json(torrent, include_rel_score=False): "num_leechers": torrent[6] or 0, "last_tracker_check": torrent[7] or 0, "commit_status": torrent[10] if len(torrent) >= 11 else 0, - "date": str(time2float(torrent[11])) if len(torrent) >= 12 else 0, + "date": str(time2int(torrent[11])) if len(torrent) >= 12 else 0, "type": str('channel' if len(torrent) >= 13 and torrent[12] == CHANNEL_TORRENT else 'torrent'), "public_key": str(torrent[13]) if len(torrent) >= 14 else '', "relevance_score": torrent[9] if include_rel_score else 0, diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py new file mode 100644 index 00000000000..feaa02504e3 --- /dev/null +++ b/Tribler/Core/Upgrade/db71_to_pony.py @@ -0,0 +1,39 @@ +import os + +import lz4 +import apsw +import lz4.frame + + +lz4.frame +select_channels_sql = "Select name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam "\ + + "FROM Channels " \ + + "WHERE nr_torrents >= 3 " \ + + "AND " + +class DispersyToPonyMigration(object): + + def __init__(self, tribler_db, dispersy_db, metadata_store): + self.tribler_db = tribler_db + self.dispersy_db = dispersy_db + self.mds = metadata_store + + def get_old_channels(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + + channels = [] + for name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute(select_channels_sql): + channels.append = {"title":name, + "public_key":dispersy_cid, + "timestamp":modified, + "version": nr_torrents, + "votes":nr_favorite, + "nr_spam) + + + + + + + def migrate_channels(self): diff --git a/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py b/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py new file mode 100644 index 00000000000..fda97a70409 --- /dev/null +++ b/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py @@ -0,0 +1,65 @@ +import os +import random +from datetime import datetime + +from pony.orm import db_session + +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.TorrentDef import TorrentDef +from Tribler.Test.Core.Modules.MetadataStore.test_channel_download import CHANNEL_METADATA, CHANNEL_TORRENT, \ + CHANNEL_TORRENT_UPDATED, CHANNEL_METADATA_UPDATED +from Tribler.Test.common import TORRENT_UBUNTU_FILE, TORRENT_VIDEO_FILE +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + +DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(os.path.realpath(__file__))), '..', '..', 'data') +SAMPLE_DIR = os.path.join(DATA_DIR, 'sample_channel') + +my_key = default_eccrypto.generate_key(u"curve25519") + + +def gen_random_entry(): + return { + "title": "test entry " + str(random.randint(0, 1000000)), + "infohash": str(random.getrandbits(160)), + "torrent_date": datetime(1970, 1, 1), + "size": 100 + random.randint(0, 10000), + "tags": "video", + "status": NEW + } + + +@db_session +def gen_sample_channel(mds): + my_channel = mds.ChannelMetadata.create_channel('test_channel', 'test description') + + t1 = my_channel.add_torrent_to_channel(TorrentDef.load(TORRENT_UBUNTU_FILE), None) + my_channel.commit_channel_torrent() + + t2 = my_channel.add_torrent_to_channel(TorrentDef.load(TORRENT_VIDEO_FILE), None) + t3 = mds.TorrentMetadata.from_dict(gen_random_entry()) + t4 = mds.TorrentMetadata.from_dict(gen_random_entry()) + my_channel.commit_channel_torrent() + + my_channel.delete_torrent_from_channel(t2.infohash) + my_channel.commit_channel_torrent() + + # Rename files to stable names + mdblob_name = os.path.join(SAMPLE_DIR, my_channel.dir_name + ".mdblob") + torrent_name = os.path.join(SAMPLE_DIR, my_channel.dir_name + ".torrent") + + os.rename(mdblob_name, CHANNEL_METADATA) + os.rename(torrent_name, CHANNEL_TORRENT) + + # Update channel + t5 = mds.TorrentMetadata.from_dict(gen_random_entry()) + my_channel.commit_channel_torrent() + + # Rename updated files to stable names + os.rename(mdblob_name, CHANNEL_METADATA_UPDATED) + os.rename(torrent_name, CHANNEL_TORRENT_UPDATED) + + +if __name__ == "__main__": + mds = MetadataStore(":memory:", SAMPLE_DIR, my_key) + gen_sample_channel(mds) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py index 2b4907bc184..9f59062881a 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py @@ -6,7 +6,6 @@ from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.exceptions import InvalidSignatureException from Tribler.Test.test_as_server import TestAsServer from Tribler.Test.tools import trial_timeout @@ -43,7 +42,6 @@ def test_channel_update_and_download(self): yield self.setup_seeder(channel_tdef, CHANNEL_DIR, libtorrent_port) payload = ChannelMetadataPayload.from_file(CHANNEL_METADATA_UPDATED) - # Download the channel in our session with db_session: channel = self.session.lm.mds.process_payload(payload) @@ -55,4 +53,4 @@ def test_channel_update_and_download(self): # There should be 4 torrents + 1 channel torrent channel2 = self.session.lm.mds.ChannelMetadata.get_channel_with_id(payload.public_key) self.assertEqual(5, len(list(self.session.lm.mds.TorrentMetadata.select()))) - self.assertEqual(6, channel2.local_version) + self.assertEqual(13, channel2.local_version) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index 00b2d0fefc5..3497ee42a38 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -7,7 +7,7 @@ from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore @@ -52,11 +52,10 @@ def get_sample_torrent_dict(my_key): return { "infohash": database_blob("1" * 20), "size": 123, - "timestamp": datetime.utcnow(), "torrent_date": datetime.utcnow(), "tags": "bla", - "tc_pointer": 123, - "public_key": database_blob(my_key.pub().key_to_bin()), + "id_": 123, + "public_key": database_blob(my_key.pub().key_to_bin()[10:]), "title": "lalala" } @@ -65,7 +64,7 @@ def get_sample_channel_dict(my_key): """ Utility method to return a dictionary with a channel information. """ - return dict(TestChannelMetadata.get_sample_torrent_dict(my_key), votes=222, subscribed=False, version=1) + return dict(TestChannelMetadata.get_sample_torrent_dict(my_key), votes=222, subscribed=False, timestamp=1) @db_session def test_serialization(self): @@ -113,7 +112,7 @@ def test_update_metadata(self): channel_metadata = self.mds.ChannelMetadata.from_dict(sample_channel_dict) self.mds.TorrentMetadata.from_dict(self.torrent_template) update_dict = { - "tc_pointer": 222, + "id_": 222, "tags": "eee", "title": "qqq" } @@ -134,10 +133,10 @@ def test_process_channel_metadata_payload(self): self.assertEqual(len(self.mds.ChannelMetadata.select()), 1) # Check that we always take the latest version - channel_metadata.version -= 1 - self.assertEqual(channel_metadata.version, 4) + channel_metadata.timestamp -= 1 + self.assertEqual(channel_metadata.timestamp, 10) channel_metadata = self.mds.ChannelMetadata.process_channel_metadata_payload(payload) - self.assertEqual(channel_metadata.version, 5) + self.assertEqual(channel_metadata.timestamp, 11) self.assertEqual(len(self.mds.ChannelMetadata.select()), 1) @db_session @@ -148,7 +147,7 @@ def test_get_dirname(self): sample_channel_dict = TestChannelMetadata.get_sample_channel_dict(self.my_key) channel_metadata = self.mds.ChannelMetadata.from_dict(sample_channel_dict) - self.assertEqual(len(channel_metadata.dir_name), 60) + self.assertEqual(len(channel_metadata.dir_name), CHANNEL_DIR_NAME_LENGTH) @db_session def test_get_channel_with_dirname(self): @@ -177,8 +176,9 @@ def test_add_metadata_to_channel(self): dict(self.torrent_template, public_key=channel_metadata.public_key, status=NEW)) channel_metadata.commit_channel_torrent() - self.assertEqual(channel_metadata.version, 1) - self.assertEqual(channel_metadata.size, 1) + self.assertEqual(channel_metadata.id_, 1) + self.assertEqual(channel_metadata.timestamp, 4) + self.assertEqual(channel_metadata.num_entries, 1) @db_session def test_add_torrent_to_channel(self): diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_serialize.py b/Tribler/Test/Core/Modules/MetadataStore/test_serialize.py index 0fa8f6cf0f2..e1604c5c2bb 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_serialize.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_serialize.py @@ -1,6 +1,6 @@ import datetime -from Tribler.Core.Modules.MetadataStore.serialization import float2time, time2float, EPOCH +from Tribler.Core.Modules.MetadataStore.serialization import EPOCH, time2int, int2time from Tribler.Test.Core.base_test import TriblerCoreTest @@ -11,22 +11,22 @@ def test_time_convert(self): Test converting various datetime objects to float """ test_time_list = [ - datetime.datetime(2005, 7, 14, 12, 30, 12, 1234), - datetime.datetime(2039, 7, 14, 12, 30, 12, 1234), - datetime.datetime.utcnow() + datetime.datetime(2005, 7, 14, 12, 30, 12), + datetime.datetime(2039, 7, 14, 12, 30, 12), + datetime.datetime.utcnow().replace(second=0, microsecond=0) ] for test_time in test_time_list: - self.assertTrue(test_time == float2time(time2float(test_time))) + self.assertTrue(test_time == int2time(time2int(test_time))) def test_zero_time(self): """ Test whether a time of zero converts to the epoch time """ - self.assertTrue(float2time(0.0) == EPOCH) + self.assertTrue(int2time(0.0) == EPOCH) def test_negative_time(self): """ Test whether we are able to deal with time below the epoch time """ negative_time = EPOCH - datetime.timedelta(1) - self.assertTrue(negative_time == float2time(time2float(negative_time))) + self.assertTrue(negative_time == int2time(time2int(negative_time))) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 989ed814ef3..2733f531140 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -1,12 +1,12 @@ from __future__ import absolute_import + import os -from datetime import datetime from pony.orm import db_session from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import (ChannelMetadataPayload, MetadataPayload, UnknownBlobTypeException) @@ -18,7 +18,7 @@ def make_wrong_payload(filename): key = default_eccrypto.generate_key(u"curve25519") - metadata_payload = MetadataPayload(666, database_blob(key.pub().key_to_bin()), datetime.utcnow(), 123) + metadata_payload = MetadataPayload(666, database_blob(key.pub().key_to_bin()[10:]), 123) with open(filename, 'wb') as output_file: output_file.write(''.join(metadata_payload.serialized(key))) @@ -28,17 +28,17 @@ class TestMetadataStore(TriblerCoreTest): This class contains tests for the metadata store. """ DATA_DIR = os.path.join(os.path.abspath(os.path.dirname(os.path.realpath(__file__))), '..', '..', 'data') - CHANNEL_DIR = os.path.join(DATA_DIR, 'sample_channel', - '4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6') + SAMPLE_DIR = os.path.join(DATA_DIR, 'sample_channel') + # Just get the first and only subdir there, and assume it is the sample channel dir + CHANNEL_DIR = [os.path.join(SAMPLE_DIR, subdir) for subdir in os.listdir(SAMPLE_DIR) if + os.path.isdir(os.path.join(SAMPLE_DIR, subdir)) and len(subdir) == CHANNEL_DIR_NAME_LENGTH][0] CHANNEL_METADATA = os.path.join(DATA_DIR, 'sample_channel', 'channel.mdblob') @inlineCallbacks def setUp(self): yield super(TestMetadataStore, self).setUp() my_key = default_eccrypto.generate_key(u"curve25519") - - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - my_key) + self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, my_key) @inlineCallbacks def tearDown(self): @@ -77,12 +77,13 @@ def test_squash_mdblobs(self): chunk_size = self.mds.ChannelMetadata._CHUNK_SIZE_LIMIT md_list = [self.mds.TorrentMetadata(title='test' + str(x)) for x in xrange(0, 10)] chunk, _ = entries_to_chunk(md_list, chunk_size=chunk_size) - self.assertItemsEqual(md_list, self.mds.process_squashed_mdblob(chunk)) + self.assertItemsEqual(md_list, self.mds.process_compressed_mdblob(chunk)) # Test splitting into multiple chunks - chunk, index = entries_to_chunk(md_list, chunk_size=1000) - chunk += entries_to_chunk(md_list, chunk_size=1000, start_index=index)[0] - self.assertItemsEqual(md_list, self.mds.process_squashed_mdblob(chunk)) + chunk, index = entries_to_chunk(md_list, chunk_size=600) + chunk2, _ = entries_to_chunk(md_list, chunk_size=600, start_index=index) + self.assertItemsEqual(md_list[:index], self.mds.process_compressed_mdblob(chunk)) + self.assertItemsEqual(md_list[index:], self.mds.process_compressed_mdblob(chunk2)) @db_session def test_multiple_squashed_commit_and_read(self): @@ -115,4 +116,4 @@ def test_process_channel_dir(self): self.assertFalse(channel.contents_list) self.mds.process_channel_dir(self.CHANNEL_DIR, channel.public_key) self.assertEqual(len(channel.contents_list), 3) - self.assertEqual(channel.local_version, 5) + self.assertEqual(channel.local_version, 10) diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..66f5f4d485d493d25db36bc6bd6f56d603756152 GIT binary patch literal 283 zcmV+$0p$J!B25@TK)?(E008kf00002IXUb%L}3l{Bc)=0Q`y- zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`W+O;`h|kp_HNze_Vm3qouYoI^yP+>QZm=e`P=rMgTBXH9Hju hsetvUP3Qwa>$c$4R4iR+WG(E?o=k+y)&&3n001ogaKiuq literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..f5dfae566eb5f1d9502c0200da8f1d703b954b92 GIT binary patch literal 537 zcmV+!0_Ob$B25@TK)?n8008kf00002IXUb%L}3l{Bj zVQy}3b#7y2J!x#i!@eu|<9|l`Sz4d879DGO3>+$k39Lf2Pdc-S+^Fzg;Gg)@YV~OE zB~05>6=pbt4c6-Ao>-rx>fpZ$hyVZp4+jA_5e5VS@dy9_05Lc=GB!0aH#9UdFg7cr=OSeImp%K#dmI4dw9+ bUR^DjjqnRWu(qAt*ny$~EzSH000000CN{+= literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..cc12927d794f6cd4f0d55527928d48d0e4ed308f GIT binary patch literal 219 zcmV<103`neB25@TK)}oZ0Du4h00cQX>^DSV4f7=IiU#BU#yoDWRK3vf0lO&y5 zI5I{&GVy@=yxTZ+hJO2j=vnX)d6+CItp?}Br%oyG+OYrt0000000lj1Y{SF8EBWJp zM*CS>pR^VoYk3SDDuxNHLbOjhvx(fO@Lu4b_|t0jXzwLV+fx;0ID-w=>gAqTpQP&G zzY5=u)%px_y=d>{=zNG(go}VEi~f7=`RT*pd1umRvQb+1N=tx!ZIafW6oO|;5kM{f VbS)b)aB{pQG%rALFb4nt003KMWj6o- literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..0ac131edd3ae6df3cb370d509d9a28a116dc02cf GIT binary patch literal 224 zcmZQk@|AFKIB=1Hf#IV$kYKX3d}HpC$opC4O&8naf5&X{)`jf7@PTo+CZmwU!yF4E zHyfjm4Zrr>wkU0@``!2=@`GUI3?0q2Y|oD@_tX4vYa`GI20L&pwSG$$O9&B||QPEVt$+2XXgwr#(Ag{!4 V%{@|W-S4kxKHgL3dV~dJ1^^|kP@Dh& literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000001.mdblob deleted file mode 100644 index ab88b10d6fff2ded248361fd666ee167111a47ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxW`KeZb=<5iCDGNVO++}iy*I$m)oAyo>%4&|- zJ|%sj@!ycbl~$_3Pr{<>r>${qSQ~V@>dLLi)u#_Ubn(}0VePv#{e-D@B3JOI<2(S4 CdTXQr diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000003.mdblob deleted file mode 100644 index 5e7d3aebb7d7bc358b7f5902c43e46aba917a55c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 561 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSs`s`{w!XD0}=^e0s$O zy9r6t_r>(?14$M2f)&V>CY9!ultACFHVPU zd$%Bdo_V0B)7on?Yg`^4*|h4x=M@_sJ!z~C*9&F-UvcM?5!<2JZx+eD@D1VxT1TT` z=<&M!^YycQ*#`e@OMcHTnU-UoLW?tT7(f4ZqT5} z$;>OQC@D%z&Q2}T%P&f|GBP(b&^I6e;2|}utnRB$Q0S&@_eEce|7m64B@h=8IDM{o u?gjyq^v~ZeF3xI<@~C;J*|Bua&DA!Km+=ibj&QGDw$j3vn=Dm^`?6NF zFRLv6!0v&wIbgi1d`Ylgfm2f7emSW%aq7j3W*G-vbn(W7dCT1b51DTt+<*{eL`rVVhJSbfl@+9ANOWv-O5d1eZ5yi2wiq diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000005.mdblob deleted file mode 100644 index 96f48bd558ef5b8b43085150ae52bb2c3b1ba84f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 300 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSoZ*uH#BUAOQ3e^YZdb^O94=4i~nt{QP_9 z(d@QK-A?u^&U`xOFC`qiUT&gb=Dx|Z&sUem$(MXvrgtnxBFc0D-`gcW=Fikql$aTJ L`={$frE?qr$}Mwt diff --git a/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob b/Tribler/Test/Core/data/sample_channel/4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb6/000000000006.mdblob deleted file mode 100644 index 3bea6dad6fccb56dc7a10b1236e4c8943d412a48..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 298 zcmZQzU|{meO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOo`VSs`juiHOgKg*YG@ZYxN z_w15sIab>^K~m-dU z$~7g(R^+Bd;l8LFC))LVS7pquyCHNWxXSgb`GLPd`l0U8+C>tDl`aKks~w$RzBrVU IDgKTV0MDdxzW@LL diff --git a/Tribler/Test/Core/data/sample_channel/channel.mdblob b/Tribler/Test/Core/data/sample_channel/channel.mdblob index 94f8bf8ec8e949e2d09376c019a49e8eccd8d930..d40229ec05e34aa35e65d58695f0bf466de7ec1a 100644 GIT binary patch literal 228 zcmZQzU|_bid}HpC$opC4O&8naf5&X{)`jf7@PTo+CZmwU!yF4EHyfjm4Zrr>wkU0@ z``!2=@`GUI3?0q2Y|oD@_tX4vYa;^$FhXhWb8UVrwibvOmCC!F3vLyl<{rX=iS| z<(1LD*>&rJNsCVZ*i`wl`q%s$PfDgUPm>e4`EG~znZqltZawBI*)I0ON;TcbZ6WiC ItFgXp0BC7g!TeuTJ2^jJV6^wnI&(D2OfciqlIZJu{dOpxVSoargKi5K|G$|Lw!m<% zgwgqe8S|ptp;F91nx~{FGbtyvD83}MxCF=-K<39Mx7np3ruKgbsD z+%wBTUSa!(G`>Y&`OJ)_dR`G%^!z#TD5vjvEf@Wqnlq{Kj1gHI0=2*2kzFsBsaax@ G#s~n)TUd(# diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index 4807a5b1fca..3511255bdec 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1544611375e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi218e4:pathl19:000000000004.mdblobeed6:lengthi300e4:pathl19:000000000005.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb612:piece lengthi16384e6:pieces20:ç¢'U²2mðŽUú(±®7°Þee \ No newline at end of file +d13:creation datei1546881510e4:infod5:filesld6:lengthi537e4:pathl23:000000000008.mdblob.lz4eed6:lengthi283e4:pathl23:000000000003.mdblob.lz4eed6:lengthi219e4:pathl23:000000000010.mdblob.lz4eee4:name32:3939ec3744610df324ec8a06e3fec63c12:piece lengthi16384e6:pieces20:yòÓáª,ÄdÈyª·õ±^M2×@ee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index ae069bbf3c564aeb35ace424a417c02e832311d6..3ef7b603e5e0eb8e333abbd75c7852b12f586664 100644 GIT binary patch literal 228 zcmZQzU|_bid}HpC$opC4O&8naf5&X{)`jf7@PTo+CZmwU!yF4EHyfjm4Zrr>wkU0@ z``!2=@`GUI3?0q2Y|oD@_tX4vYa;^$FhXfQ7x7qLuHQjr!95L*BAI<@YO1q;LFHIt zj60tJ**qnw#U=5{8Hss$sX0Kt0En-Ul3JWxlvz-cnV$z$529F{o|#^m;FR*g+xhg7 z%1s@mUP^1@B9fkSl<9t(^ReW2*xdO^H};zNz529u%d&t8Y~SmZl&=UZczIxDDbq*Z HRjauGO|@3x literal 230 zcmZQzU|{yiO!7-~_6hK|O3HZe((}euTJ2^jJV6^wnI&(D2OfciqlIZJu{dOo`VSs`&Y7==|`q%C?()la- zW4m+YwkvZ_K&4oKG*3xUW>QXSQG7{iaS4zwfXt6i%1MN(1yO8rIqqdDg+?Cnj;AiL zF5MnecJ}wN!cv|k_u?1rG`stWIcu-@ij=uaA3068ZR$wr=I)GseN|`0f5Gqvv#gd> HiZKEJfks^| diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent index b2af1e71af4..4d9157ae762 100644 --- a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent @@ -1 +1 @@ -d13:creation datei1544612327e4:infod5:filesld6:lengthi561e4:pathl19:000000000003.mdblobeed6:lengthi298e4:pathl19:000000000006.mdblobeed6:lengthi218e4:pathl19:000000000004.mdblobeed6:lengthi300e4:pathl19:000000000005.mdblobeed6:lengthi265e4:pathl19:000000000001.mdblobeee4:name60:4c69624e61434c504b3a6268ef448cf1476392eaf8856b89008e70af5eb612:piece lengthi16384e6:pieces20:v_Sf• úÆVѻŠË÷Pï¦ZÆee \ No newline at end of file +d13:creation datei1546881510e4:infod5:filesld6:lengthi537e4:pathl23:000000000008.mdblob.lz4eed6:lengthi283e4:pathl23:000000000003.mdblob.lz4eed6:lengthi224e4:pathl23:000000000013.mdblob.lz4eed6:lengthi219e4:pathl23:000000000010.mdblob.lz4eee4:name32:3939ec3744610df324ec8a06e3fec63c12:piece lengthi16384e6:pieces20:n›Ïo:AÀ&¿)WμªÊr±lee \ No newline at end of file diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index 7b3da6378ff..4b342cd34c6 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -68,7 +68,7 @@ def on_blob(self, source_address, data): if not signature_valid: raise PacketDecodingError("Incoming packet %s has an invalid signature" % str(self.__class__)) - self.metadata_store.process_squashed_mdblob(blob) + self.metadata_store.process_compressed_mdblob(blob) class GigaChannelTestnetCommunity(GigaChannelCommunity): diff --git a/Tribler/dispersy b/Tribler/dispersy index 51045631865..23d0b846631 160000 --- a/Tribler/dispersy +++ b/Tribler/dispersy @@ -1 +1 @@ -Subproject commit 5104563186541a3413da34f6cce5123d972a2a47 +Subproject commit 23d0b84663138b58fb5c16ac75042b2de0a68ef5 diff --git a/TriblerGUI/tribler_app.py b/TriblerGUI/tribler_app.py index c77cfae5f66..1901f8e928c 100644 --- a/TriblerGUI/tribler_app.py +++ b/TriblerGUI/tribler_app.py @@ -2,6 +2,7 @@ import sys from PyQt5.QtCore import QEvent +from PyQt5.QtWidgets import QApplication from TriblerGUI.code_executor import CodeExecutor from TriblerGUI.single_application import QtSingleApplication @@ -12,7 +13,7 @@ class TriblerApplication(QtSingleApplication): This class represents the main Tribler application. """ def __init__(self, app_name, args): - QtSingleApplication.__init__(self, app_name, args) + QApplication.__init__(self, args) self.code_executor = None self.messageReceived.connect(self.on_app_message) diff --git a/TriblerGUI/widgets/channelview.py b/TriblerGUI/widgets/channelview.py index 3b2512f261d..7102d54cbb3 100644 --- a/TriblerGUI/widgets/channelview.py +++ b/TriblerGUI/widgets/channelview.py @@ -10,7 +10,7 @@ from PyQt5.QtWidgets import QWidget, QAction, QFileDialog, QAbstractItemView from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, COMMITTED, NEW -from Tribler.Core.Modules.MetadataStore.serialization import float2time +from Tribler.Core.Modules.MetadataStore.serialization import int2time from Tribler.Core.Modules.restapi.util import HEALTH_MOOT, HEALTH_GOOD, HEALTH_DEAD, HEALTH_CHECKING, HEALTH_ERROR from TriblerGUI.defs import BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, \ PAGE_EDIT_CHANNEL_CREATE_TORRENT @@ -55,7 +55,7 @@ class ChannelContentsModel(RemoteTableModel): column_display_filters = { u'size': lambda data: format_size(float(data)), - u'date': lambda data: str((float2time(float(data)).strftime("%Y-%m-%d"))) + u'date': lambda data: str((int2time(int(data)).strftime("%Y-%m-%d"))) } def __init__(self, parent=None, channel_id=None, search_query=None, search_type=None, subscribed=None, diff --git a/logger.conf b/logger.conf index 462e3a03399..ba5a15feb5d 100644 --- a/logger.conf +++ b/logger.conf @@ -28,6 +28,8 @@ keys=root,candidates,twisted, DHTDiscoveryCommunity, PreviewChannelCommunity, MarketCommunity, + + MetadataStore, TunnelDispatcher, TunnelMain, @@ -92,7 +94,7 @@ args=(4*1024,) # 4KB buffer [logger_root] level=INFO -handlers=default,infoMemoryHandler,errorHandler +handlers=default,infoMemoryHandler,errorHandler,debugging [logger_candidates] level=ERROR @@ -292,3 +294,9 @@ level=INFO qualname=ResourceMonitor handlers=default propagate=0 + +[logger_MetadataStore] +level=DEBUG +qualname=MetadataStore +handlers=default +propagate=1 diff --git a/run_tribler.py b/run_tribler.py index 427d76b4aa6..2c0f336b926 100644 --- a/run_tribler.py +++ b/run_tribler.py @@ -3,6 +3,7 @@ import logging.config import signal +import random from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.exceptions import TriblerException @@ -114,15 +115,7 @@ def start_tribler(): app = TriblerApplication("triblerapp", sys.argv) - if app.is_running(): - for arg in sys.argv[1:]: - if os.path.exists(arg) and arg.endswith(".torrent"): - app.send_message("file:%s" % arg) - elif arg.startswith('magnet'): - app.send_message(arg) - sys.exit(1) - - window = TriblerWindow() + window = TriblerWindow(api_port=random.randint(10000,20000)) window.setWindowTitle("Tribler") app.set_activation_window(window) app.parse_sys_args(sys.argv) From ddc48154519c9677eda2a2bb042926c3eb09cde9 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Tue, 8 Jan 2019 20:54:35 +0100 Subject: [PATCH 03/38] Add ChannelNode ORM class --- .../MetadataStore/OrmBindings/author.py | 11 +++ .../OrmBindings/channel_metadata.py | 6 +- .../MetadataStore/OrmBindings/channel_node.py | 26 +++++ .../MetadataStore/OrmBindings/metadata.py | 6 +- .../OrmBindings/torrent_metadata.py | 12 ++- .../OrmBindings/torrent_state.py | 16 +++ .../Modules/MetadataStore/serialization.py | 92 +++++++++++++----- Tribler/Core/Modules/MetadataStore/store.py | 8 +- .../MetadataStore/test_channel_download.py | 2 +- .../MetadataStore/test_channel_metadata.py | 11 ++- .../Modules/MetadataStore/test_metadata.py | 1 + .../Core/Modules/MetadataStore/test_store.py | 4 +- .../000000000003.mdblob.lz4 | Bin 283 -> 0 bytes .../000000000008.mdblob.lz4 | Bin 537 -> 0 bytes .../000000000010.mdblob.lz4 | Bin 219 -> 0 bytes .../000000000013.mdblob.lz4 | Bin 224 -> 0 bytes .../000000000002.mdblob.lz4 | Bin 0 -> 283 bytes .../000000000007.mdblob.lz4 | Bin 0 -> 539 bytes .../000000000009.mdblob.lz4 | Bin 0 -> 219 bytes .../000000000012.mdblob.lz4 | Bin 0 -> 223 bytes .../Core/data/sample_channel/channel.mdblob | Bin 228 -> 236 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 228 -> 236 bytes .../data/sample_channel/channel_upd.torrent | 2 +- 24 files changed, 151 insertions(+), 48 deletions(-) create mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/author.py create mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py create mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py delete mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000012.mdblob.lz4 diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py new file mode 100644 index 00000000000..e10393905ea --- /dev/null +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py @@ -0,0 +1,11 @@ +from pony import orm + +from Tribler.pyipv8.ipv8.database import database_blob + + +def define_binding(db): + class Author(db.Entity): + public_key = orm.PrimaryKey(database_blob) + authored = orm.Set('Metadata') + + return Author diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 28247113dc8..63c17fbd5a4 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -16,6 +16,7 @@ CHANNEL_DIR_NAME_LENGTH = 32 # Its not 40 so it could be distinguished from infohash BLOB_EXTENSION = '.mdblob' LZ4_END_MARK_SIZE = 4 # in bytes, from original specification. We don't use CRC +ROOT_CHANNEL_ID = 0 def create_torrent_from_dir(directory, torrent_filename): @@ -128,8 +129,8 @@ def create_channel(cls, title, description): if ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()[10:]): raise DuplicateChannelNameError() - my_channel = cls(public_key=database_blob(cls._my_key.pub().key_to_bin()[10:]), title=title, - tags=description, subscribed=True) + my_channel = cls(id_=ROOT_CHANNEL_ID, public_key=database_blob(cls._my_key.pub().key_to_bin()[10:]), + title=title, tags=description, subscribed=True) my_channel.sign() return my_channel @@ -246,6 +247,7 @@ def add_torrent_to_channel(self, tdef, extra_info): "tracker_info": tdef.get_tracker() or '', "status": NEW }) + torrent_metadata.parents.add(self) return torrent_metadata @property diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py new file mode 100644 index 00000000000..b6bbe5f8c88 --- /dev/null +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -0,0 +1,26 @@ +from pony import orm + +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_NODE, ChannelNodePayload + + +def define_binding(db): + class ChannelNode(db.Metadata): + _discriminator_ = CHANNEL_NODE + + # Serializable + id_ = orm.Optional(int, size=64, default=0) + origin_id = orm.Optional(int, size=64, default=0) + + # Local + parents = orm.Set('ChannelNode', reverse='children') + children = orm.Set('ChannelNode', reverse='parents') + + def __init__(self, *args, **kwargs): + if "id_" not in kwargs: + kwargs["id_"] = self._clock.tick() + super(ChannelNode, self).__init__(*args, **kwargs) + + # Special properties + _payload_class = ChannelNodePayload + + return ChannelNode diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index fa12af26864..d1696f18395 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -29,7 +29,6 @@ class Metadata(db.Entity): metadata_type = orm.Discriminator(int) # We want to make signature unique=True for safety, but can't do it in Python2 because of Pony bug #390 signature = orm.Optional(database_blob) - id_ = orm.Optional(int, size=64, default=0) public_key = orm.Optional(database_blob, default='\x00' * PUBLIC_KEY_LEN) # Local @@ -44,9 +43,6 @@ class Metadata(db.Entity): _clock = None def __init__(self, *args, **kwargs): - if "id_" not in kwargs: - kwargs["id_"] = self._clock.tick() - # Special "sign_with" argument given, sign with it private_key_override = None if "sign_with" in kwargs: @@ -138,7 +134,7 @@ def sign(self, key=None): def has_valid_signature(self): crypto = default_eccrypto - return (crypto.is_valid_public_bin(b"LibNaCLPK:"+str(self.public_key)) + return (crypto.is_valid_public_bin(b"LibNaCLPK:" + str(self.public_key)) and self._payload_class(**self.to_dict()).has_valid_signature()) @classmethod diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 9a774a61d4e..2fbfe1610da 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -1,4 +1,5 @@ from __future__ import absolute_import + from datetime import datetime from pony import orm @@ -9,11 +10,10 @@ def define_binding(db): - class TorrentMetadata(db.Metadata): + class TorrentMetadata(db.ChannelNode): _discriminator_ = REGULAR_TORRENT # Serializable - timestamp = orm.Required(int, size=64, default=0) infohash = orm.Optional(database_blob, default='\x00' * 20) size = orm.Optional(int, size=64, default=0) @@ -24,9 +24,16 @@ class TorrentMetadata(db.Metadata): # Local xxx = orm.Optional(float, default=0) + health = orm.Optional('TorrentState', reverse='metadata') _payload_class = TorrentMetadataPayload + def __init__(self, *args, **kwargs): + if "health" not in kwargs and "infohash" in kwargs: + ts = db.TorrentState.get(infohash=kwargs["infohash"]) + kwargs["health"] = ts or db.TorrentState(infohash=kwargs["infohash"]) + super(TorrentMetadata, self).__init__(*args, **kwargs) + def get_magnet(self): return ("magnet:?xt=urn:btih:%s&dn=%s" % (str(self.infohash).encode('hex'), self.title)) + \ @@ -50,7 +57,6 @@ def search_keyword(cls, query, entry_type=None, lim=100): "SELECT rowid FROM FtsIndex WHERE FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT %d" % lim) return cls.select(lambda g: g.rowid in fts_ids) - @classmethod def get_auto_complete_terms(cls, keyword, max_terms, limit=100): with db_session: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py new file mode 100644 index 00000000000..97af956d999 --- /dev/null +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py @@ -0,0 +1,16 @@ +from datetime import datetime + +from pony import orm + +from Tribler.pyipv8.ipv8.database import database_blob + + +def define_binding(db): + class TorrentState(db.Entity): + infohash = orm.PrimaryKey(database_blob) + seeders = orm.Optional(int, default=0) + leechers = orm.Optional(int, default=0) + last_check = orm.Optional(datetime, default=datetime.utcnow) + metadata = orm.Set('TorrentMetadata') + + return TorrentState diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index b47caf7e56a..b959e01c502 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -16,11 +16,13 @@ SIGNATURE_SIZE = 64 EMPTY_SIG = '0' * 64 + # Metadata types. Should have been an enum, but in Python its unwieldy. -TYPELESS = 1 -REGULAR_TORRENT = 2 -CHANNEL_TORRENT = 3 -DELETED = 4 +TYPELESS = 100 +CHANNEL_NODE = 200 +REGULAR_TORRENT = 300 +CHANNEL_TORRENT = 400 +DELETED = 500 def time2int(date_time, epoch=EPOCH): @@ -77,13 +79,12 @@ class MetadataPayload(Payload): Payload for metadata. """ - format_list = ['I', '64s', 'Q'] + format_list = ['I', '64s'] - def __init__(self, metadata_type, public_key, id_, **kwargs): + def __init__(self, metadata_type, public_key, **kwargs): super(MetadataPayload, self).__init__() self.metadata_type = metadata_type self.public_key = str(public_key) - self.id_ = id_ self.signature = str(kwargs["signature"]) if "signature" in kwargs else EMPTY_SIG def has_valid_signature(self): @@ -94,13 +95,12 @@ def has_valid_signature(self): def to_pack_list(self): data = [('I', self.metadata_type), - ('64s', self.public_key), - ('Q', self.id_)] + ('64s', self.public_key)] return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, id_): - return MetadataPayload(metadata_type, public_key, id_) + def from_unpack_list(cls, metadata_type, public_key): + return MetadataPayload(metadata_type, public_key) @classmethod def from_signed_blob(cls, data, check_signature=True): @@ -122,7 +122,6 @@ def to_dict(self): return { "metadata_type": self.metadata_type, "public_key": self.public_key, - "id_": self.id_, "signature": self.signature } @@ -153,16 +152,50 @@ def from_file(cls, filepath): return cls.from_signed_blob(f.read()) -class TorrentMetadataPayload(MetadataPayload): +class ChannelNodePayload(MetadataPayload): + format_list = MetadataPayload.format_list + ['Q'] + ['Q'] + + def __init__(self, metadata_type, public_key, + id_, origin_id, + **kwargs): + super(ChannelNodePayload, self).__init__(metadata_type, public_key, + **kwargs) + self.id_ = id_ + self.origin_id = origin_id + + def to_pack_list(self): + data = super(ChannelNodePayload, self).to_pack_list() + data.append(('Q', self.id_)) + data.append(('Q', self.origin_id)) + return data + + @classmethod + def from_unpack_list(cls, metadata_type, public_key, + id_, origin_id): + return ChannelNodePayload(metadata_type, public_key, + id_, origin_id) + + def to_dict(self): + dct = super(ChannelNodePayload, self).to_dict() + dct.update({ + "id_": self.id_, + "origin_id": self.origin_id + }) + return dct + + +class TorrentMetadataPayload(ChannelNodePayload): """ Payload for metadata that stores a torrent. """ - format_list = MetadataPayload.format_list + ['Q', '20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] + format_list = ChannelNodePayload.format_list + ['Q', '20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] - def __init__(self, metadata_type, public_key, id_, + def __init__(self, metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): - super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, id_, + super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, + id_, origin_id, **kwargs) self.timestamp = timestamp self.infohash = str(infohash) @@ -184,9 +217,11 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, id_, + def from_unpack_list(cls, metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info): - return TorrentMetadataPayload(metadata_type, public_key, id_, + return TorrentMetadataPayload(metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info) def to_dict(self): @@ -215,11 +250,13 @@ class ChannelMetadataPayload(TorrentMetadataPayload): """ format_list = TorrentMetadataPayload.format_list + ['Q'] - def __init__(self, metadata_type, public_key, id_, + def __init__(self, metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, num_entries, **kwargs): - super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, id_, + super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) @@ -231,10 +268,12 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, id_, + def from_unpack_list(cls, metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, num_entries): - return ChannelMetadataPayload(metadata_type, public_key, id_, + return ChannelMetadataPayload(metadata_type, public_key, + id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, num_entries) @@ -250,10 +289,9 @@ class DeletedMetadataPayload(MetadataPayload): """ format_list = MetadataPayload.format_list + ['64s'] - def __init__(self, metadata_type, public_key, id_, - delete_signature, + def __init__(self, metadata_type, public_key, delete_signature, **kwargs): - super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, id_, + super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, **kwargs) self.delete_signature = str(delete_signature) @@ -263,9 +301,9 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, id_, + def from_unpack_list(cls, metadata_type, public_key, delete_signature): - return DeletedMetadataPayload(metadata_type, public_key, id_, + return DeletedMetadataPayload(metadata_type, public_key, delete_signature) def to_dict(self): diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 3ade7d477a5..f91b13085b1 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -8,7 +8,8 @@ from pony.orm import db_session from Tribler.Core.Category.Category import Category -from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata +from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata, channel_node, \ + torrent_state from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time @@ -88,7 +89,12 @@ def __init__(self, db_filename, channels_dir, my_key): self._db = orm.Database() # Accessors for ORM-managed classes + #self.Author = author.define_binding(self._db) + + self.TorrentState = torrent_state.define_binding(self._db) + self.Metadata = metadata.define_binding(self._db) + self.ChannelNode = channel_node.define_binding(self._db) self.TorrentMetadata = torrent_metadata.define_binding(self._db) self.ChannelMetadata = channel_metadata.define_binding(self._db) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py index 9f59062881a..6c38ae0f45f 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py @@ -53,4 +53,4 @@ def test_channel_update_and_download(self): # There should be 4 torrents + 1 channel torrent channel2 = self.session.lm.mds.ChannelMetadata.get_channel_with_id(payload.public_key) self.assertEqual(5, len(list(self.session.lm.mds.TorrentMetadata.select()))) - self.assertEqual(13, channel2.local_version) + self.assertEqual(12, channel2.local_version) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index 3497ee42a38..9520a4108bf 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -7,7 +7,8 @@ from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH, \ + ROOT_CHANNEL_ID from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore @@ -134,9 +135,9 @@ def test_process_channel_metadata_payload(self): # Check that we always take the latest version channel_metadata.timestamp -= 1 - self.assertEqual(channel_metadata.timestamp, 10) + self.assertEqual(channel_metadata.timestamp, 9) channel_metadata = self.mds.ChannelMetadata.process_channel_metadata_payload(payload) - self.assertEqual(channel_metadata.timestamp, 11) + self.assertEqual(channel_metadata.timestamp, 10) self.assertEqual(len(self.mds.ChannelMetadata.select()), 1) @db_session @@ -176,8 +177,8 @@ def test_add_metadata_to_channel(self): dict(self.torrent_template, public_key=channel_metadata.public_key, status=NEW)) channel_metadata.commit_channel_torrent() - self.assertEqual(channel_metadata.id_, 1) - self.assertEqual(channel_metadata.timestamp, 4) + self.assertEqual(channel_metadata.id_, ROOT_CHANNEL_ID) + self.assertEqual(channel_metadata.timestamp, 3) self.assertEqual(channel_metadata.num_entries, 1) @db_session diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py index 6a084e5374c..61f90bc805e 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py @@ -1,5 +1,6 @@ import os +from pony import orm from pony.orm import db_session from twisted.internet.defer import inlineCallbacks diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 2733f531140..17824da6e5e 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -18,7 +18,7 @@ def make_wrong_payload(filename): key = default_eccrypto.generate_key(u"curve25519") - metadata_payload = MetadataPayload(666, database_blob(key.pub().key_to_bin()[10:]), 123) + metadata_payload = MetadataPayload(666, database_blob(key.pub().key_to_bin()[10:])) with open(filename, 'wb') as output_file: output_file.write(''.join(metadata_payload.serialized(key))) @@ -116,4 +116,4 @@ def test_process_channel_dir(self): self.assertFalse(channel.contents_list) self.mds.process_channel_dir(self.CHANNEL_DIR, channel.public_key) self.assertEqual(len(channel.contents_list), 3) - self.assertEqual(channel.local_version, 10) + self.assertEqual(channel.local_version, 9) diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000003.mdblob.lz4 deleted file mode 100644 index 66f5f4d485d493d25db36bc6bd6f56d603756152..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 283 zcmV+$0p$J!B25@TK)?(E008kf00002IXUb%L}3l{Bc)=0Q`y- zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`W+O;`h|kp_HNze_Vm3qouYoI^yP+>QZm=e`P=rMgTBXH9Hju hsetvUP3Qwa>$c$4R4iR+WG(E?o=k+y)&&3n001ogaKiuq diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000008.mdblob.lz4 deleted file mode 100644 index f5dfae566eb5f1d9502c0200da8f1d703b954b92..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 537 zcmV+!0_Ob$B25@TK)?n8008kf00002IXUb%L}3l{Bj zVQy}3b#7y2J!x#i!@eu|<9|l`Sz4d879DGO3>+$k39Lf2Pdc-S+^Fzg;Gg)@YV~OE zB~05>6=pbt4c6-Ao>-rx>fpZ$hyVZp4+jA_5e5VS@dy9_05Lc=GB!0aH#9UdFg7cr=OSeImp%K#dmI4dw9+ bUR^DjjqnRWu(qAt*ny$~EzSH000000CN{+= diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000010.mdblob.lz4 deleted file mode 100644 index cc12927d794f6cd4f0d55527928d48d0e4ed308f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 219 zcmV<103`neB25@TK)}oZ0Du4h00cQX>^DSV4f7=IiU#BU#yoDWRK3vf0lO&y5 zI5I{&GVy@=yxTZ+hJO2j=vnX)d6+CItp?}Br%oyG+OYrt0000000lj1Y{SF8EBWJp zM*CS>pR^VoYk3SDDuxNHLbOjhvx(fO@Lu4b_|t0jXzwLV+fx;0ID-w=>gAqTpQP&G zzY5=u)%px_y=d>{=zNG(go}VEi~f7=`RT*pd1umRvQb+1N=tx!ZIafW6oO|;5kM{f VbS)b)aB{pQG%rALFb4nt003KMWj6o- diff --git a/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/3939ec3744610df324ec8a06e3fec63c/000000000013.mdblob.lz4 deleted file mode 100644 index 0ac131edd3ae6df3cb370d509d9a28a116dc02cf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 224 zcmZQk@|AFKIB=1Hf#IV$kYKX3d}HpC$opC4O&8naf5&X{)`jf7@PTo+CZmwU!yF4E zHyfjm4Zrr>wkU0@``!2=@`GUI3?0q2Y|oD@_tX4vYa`GI20L&pwSG$$O9&B||QPEVt$+2XXgwr#(Ag{!4 V%{@|W-S4kxKHgL3dV~dJ1^^|kP@Dh& diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..31cbd24125420e14f28bada74cec1fd7155bb7eb GIT binary patch literal 283 zcmV+$0p$J!B25@TK)?(E008kf0003jcmZ1&LB000625dj7O2Lb@{RQ!q+ zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`cLb-|mIy&*Rk$M!#9Z)!I(mK0;@IKc#cj#(RK-K!I4?dD}O` hB){wew=lHBAF`$d!&C0BrjV*Po@Hh=_znO7001dTb7lYl literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..7af7989c260554e7a6d95533095a29605565e388 GIT binary patch literal 539 zcmV+$0_6P!B25@TK)?n8008kf0003jcmZ1&LB000625d;PR2Lb@_q>$}7 zVt8Cqr*!C-2K!He8b*$61poj54YEK008kf_V*mgE9!_azXml=5W-e4{WiCuj zVQy}3b#7y20ss4khD0dF@HPL8Nv6$fG$Wac$dok}*~e=8J$Y4(KHXI)3&N_2yah5X zLjjzANg@*SwHp4EOU+XT$a}#FhyVZp4-Wx27X=Cd0RjN=1u;1{FgZ3bGdVUnG&MLi zFgG(WI2iyk02Iv#0P_g|5p-p9bRcDJbaHthIW#ymG&leN00nje0q`;a6`I}KOOhsD zX_0;!guq#3)=X1Ajt9K8;f|5j7VxH)AvKtAAi9vdC25YT=~*;hdyrE?6-u dBByJ4^)@sl(w);F77H2N#fg(MGXekr002aVzkvV% literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..2f2bab89efc2523e617d6671f392c06d6af5f79a GIT binary patch literal 219 zcmV<103`neB25@TK)}oZ0Du4h0rYt7h37{SWZ11VqEWjWrSzdve%4BAr|AGL&elnk z=ruzGlCGLDOI3C7GwSe9csDLZ868tJf7Ql7v&wAt(AEF|0000000aU5`-X-@D8}$L z|BOkd&1*CxnTp7iH5S>&YWqETRg6B}RVWL>s)@V>GA%;^oPJ3n67#hh{*+72QwGR; z!3oJn*wJw0bM%n&D~cmD?9Qp8A|WCVtK$V+S4DW?G9@8UB1OG1C9}M5+{7mZ1&LB000625eo(Y2Lb@{1u-)+ zHZwLiG%_?eFg7wbF*!CdFdqOS5()?a@KFE}bY*jNAZ2cJa(N&*GBGnSH~;_u1$Jp< zWp4lg001^k+8Tr%^ehE(u?o{F!W-UKNLAg=gH$9AIAxaTQ6(O}kq;<0l zy+cbs7{B`9UtzB2Dk&RmQh)WB!{$>t-!5EZzz(>ct#{tMgnPzLvC?N(H`n}Qlgrt} z3RTJyV|v{J$eviP>(II`;i^=xx@#GeCe5LNRckYx7&NwLnaA!*VtCSkw9^ zhu$kHK;>9sj60tJ**qnw#U=5{857%89S*M3aN~V*)k-^a>n*R0{>`pi7ff1o`p2fq um({=K-*{3ooq3v^z|D6%yw4n7adqo4SIKs$w diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index 3511255bdec..cda6ae95d50 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1546881510e4:infod5:filesld6:lengthi537e4:pathl23:000000000008.mdblob.lz4eed6:lengthi283e4:pathl23:000000000003.mdblob.lz4eed6:lengthi219e4:pathl23:000000000010.mdblob.lz4eee4:name32:3939ec3744610df324ec8a06e3fec63c12:piece lengthi16384e6:pieces20:yòÓáª,ÄdÈyª·õ±^M2×@ee \ No newline at end of file +d13:creation datei1547032376e4:infod5:filesld6:lengthi539e4:pathl23:000000000007.mdblob.lz4eed6:lengthi219e4:pathl23:000000000009.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:78ed85e7471264d8ad34a251bb1ba5f412:piece lengthi16384e6:pieces20:žHaÄó ô:®”½# Õ{|Ü·jÃee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index 3ef7b603e5e0eb8e333abbd75c7852b12f586664..2b24dc0d65e2d00524eada7a706b1b06bb6a378a 100644 GIT binary patch delta 200 zcmaFD_=d5bfq`*C#oN~B?m{Uy)|xB|+%3KI%fg_#YhGE)Uoz;OyXHCNg{d>kq;<0l zy+cbs7{B`9UtzB2Dk&RmQh)WB!{$>t-!5EZzz%p_X4=Od>CN_9aC)uoq^(n9o5O_s zph{U{Os`u2*%Qlk9db-(t>@gZStxkLUL_}q7}gITd5^r`yJYcK-m6j;Spg~A#gA&c iME_OUWBKi)fu8#Rw9k*WJY4d5$*k{Dx+x@#GeCe5LNV~Uh{y7B{SGP%?rCro$?Qv0 zQ=R<_D#sFI-1!X1<|#=nE{RXhnAooB;PlM&$^@sB58lqFk5q2zDD_fW8yAuEoTE(l t+nkRjzr*IvPr9+!#P8Loty`7_Okn$7ucUlMV8P1+D@&O^@~&FV1pvbJQV9S6 diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent index 4d9157ae762..58e7f366ead 100644 --- a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent @@ -1 +1 @@ -d13:creation datei1546881510e4:infod5:filesld6:lengthi537e4:pathl23:000000000008.mdblob.lz4eed6:lengthi283e4:pathl23:000000000003.mdblob.lz4eed6:lengthi224e4:pathl23:000000000013.mdblob.lz4eed6:lengthi219e4:pathl23:000000000010.mdblob.lz4eee4:name32:3939ec3744610df324ec8a06e3fec63c12:piece lengthi16384e6:pieces20:n›Ïo:AÀ&¿)WμªÊr±lee \ No newline at end of file +d13:creation datei1547032376e4:infod5:filesld6:lengthi539e4:pathl23:000000000007.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eed6:lengthi219e4:pathl23:000000000009.mdblob.lz4eed6:lengthi223e4:pathl23:000000000012.mdblob.lz4eee4:name32:78ed85e7471264d8ad34a251bb1ba5f412:piece lengthi16384e6:pieces20:EÉùp«€‡÷pg$:@üvee \ No newline at end of file From 4e0321070d4c19cb4531794ecc5578515d6b51b4 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 9 Jan 2019 17:35:18 +0100 Subject: [PATCH 04/38] Add TrackerState ORM class --- .../OrmBindings/channel_metadata.py | 1 + .../MetadataStore/OrmBindings/channel_node.py | 6 +-- .../OrmBindings/torrent_state.py | 4 +- .../OrmBindings/tracker_state.py | 27 +++++++++++ Tribler/Core/Modules/MetadataStore/store.py | 3 +- .../MetadataStore/test_tracker_state.py | 43 ++++++++++++++++++ .../000000000002.mdblob.lz4 | Bin 283 -> 0 bytes .../000000000007.mdblob.lz4 | Bin 539 -> 0 bytes .../000000000009.mdblob.lz4 | Bin 219 -> 0 bytes .../000000000012.mdblob.lz4 | Bin 223 -> 0 bytes .../Core/data/sample_channel/channel.mdblob | Bin 236 -> 236 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 236 -> 236 bytes .../data/sample_channel/channel_upd.torrent | 2 +- .../000000000002.mdblob.lz4 | Bin 0 -> 283 bytes .../000000000007.mdblob.lz4 | Bin 0 -> 541 bytes .../000000000009.mdblob.lz4 | Bin 0 -> 211 bytes .../000000000012.mdblob.lz4 | Bin 0 -> 222 bytes TriblerGUI/tribler_window.py | 20 ++++++++ 19 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py create mode 100644 Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py delete mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000012.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000002.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000012.mdblob.lz4 diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 63c17fbd5a4..2b94e103b07 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -38,6 +38,7 @@ def entries_to_chunk(metadata_list, chunk_size, start_index=0): """ For efficiency reasons, this is deliberately written in C style :param metadata_list: the list of metadata to process. + :param chunk_size: the desired chunk size limit, in bytes. The produced chunk's size will never exceed this value. :param start_index: the index of the element of metadata_list from which the processing should start. :return: (chunk, last_entry_index) tuple, where chunk is the resulting chunk in string form and last_entry_index is the index of the element of the input list that was put into the chunk the last. diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index b6bbe5f8c88..0b365801eae 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -15,12 +15,12 @@ class ChannelNode(db.Metadata): parents = orm.Set('ChannelNode', reverse='children') children = orm.Set('ChannelNode', reverse='parents') + # Special properties + _payload_class = ChannelNodePayload + def __init__(self, *args, **kwargs): if "id_" not in kwargs: kwargs["id_"] = self._clock.tick() super(ChannelNode, self).__init__(*args, **kwargs) - # Special properties - _payload_class = ChannelNodePayload - return ChannelNode diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py index 97af956d999..a4c35c1f76c 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py @@ -2,6 +2,7 @@ from pony import orm +from Tribler.Core.Modules.MetadataStore.serialization import EPOCH from Tribler.pyipv8.ipv8.database import database_blob @@ -10,7 +11,8 @@ class TorrentState(db.Entity): infohash = orm.PrimaryKey(database_blob) seeders = orm.Optional(int, default=0) leechers = orm.Optional(int, default=0) - last_check = orm.Optional(datetime, default=datetime.utcnow) + last_check = orm.Optional(datetime, default=EPOCH) metadata = orm.Set('TorrentMetadata') + trackers = orm.Set('TrackerState', reverse='torrents') return TorrentState diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py new file mode 100644 index 00000000000..002fae169dc --- /dev/null +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py @@ -0,0 +1,27 @@ +from datetime import datetime + +from pony import orm + +from Tribler.Core.Modules.MetadataStore.serialization import EPOCH +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url, MalformedTrackerURLException + + +def define_binding(db): + class TrackerState(db.Entity): + url = orm.PrimaryKey(str) + last_check = orm.Optional(datetime, default=EPOCH) + alive = orm.Optional(bool, default=True) + torrents = orm.Set('TorrentState', reverse='trackers') + failures = orm.Optional(int, size=32, default=0) + + def __init__(self, *args, **kwargs): + # Sanitize and canonicalize the tracker URL + sanitized = get_uniformed_tracker_url(kwargs['url']) + if sanitized: + kwargs['url'] = sanitized + else: + raise MalformedTrackerURLException("Could not canonicalize tracker URL (%s)" % kwargs['url']) + + super(TrackerState, self).__init__(*args, **kwargs) + + return TrackerState diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index f91b13085b1..6f728803e9f 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -9,7 +9,7 @@ from Tribler.Core.Category.Category import Category from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata, channel_node, \ - torrent_state + torrent_state, tracker_state from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time @@ -91,6 +91,7 @@ def __init__(self, db_filename, channels_dir, my_key): # Accessors for ORM-managed classes #self.Author = author.define_binding(self._db) + self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) self.Metadata = metadata.define_binding(self._db) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py b/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py new file mode 100644 index 00000000000..7c87a00b395 --- /dev/null +++ b/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py @@ -0,0 +1,43 @@ +from __future__ import absolute_import + +import os + +from pony.orm import db_session +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Utilities.tracker_utils import MalformedTrackerURLException +from Tribler.Test.Core.base_test import TriblerCoreTest +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + + +class TestTrackerState(TriblerCoreTest): + """ + Contains various tests for the TrackerState class. + """ + @inlineCallbacks + def setUp(self): + yield super(TestTrackerState, self).setUp() + self.my_key = default_eccrypto.generate_key(u"curve25519") + self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, + self.my_key) + + @inlineCallbacks + def tearDown(self): + self.mds.shutdown() + yield super(TestTrackerState, self).tearDown() + + @db_session + def test_create_tracker_state(self): + ts = self.mds.TrackerState(url='http://tracker.tribler.org:80/announce') + self.assertEqual(list(self.mds.TrackerState.select())[0], ts) + + @db_session + def test_canonicalize_tracker_state(self): + ts = self.mds.TrackerState(url='http://tracker.tribler.org:80/announce/') + self.assertEqual(self.mds.TrackerState.get(url='http://tracker.tribler.org/announce'), ts) + + @db_session + def test_canonicalize_raise_on_malformed_url(self): + self.assertRaises(MalformedTrackerURLException, self.mds.TrackerState, + url='udp://tracker.tribler.org/announce/') diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000002.mdblob.lz4 deleted file mode 100644 index 31cbd24125420e14f28bada74cec1fd7155bb7eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 283 zcmV+$0p$J!B25@TK)?(E008kf0003jcmZ1&LB000625dj7O2Lb@{RQ!q+ zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`cLb-|mIy&*Rk$M!#9Z)!I(mK0;@IKc#cj#(RK-K!I4?dD}O` hB){wew=lHBAF`$d!&C0BrjV*Po@Hh=_znO7001dTb7lYl diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000007.mdblob.lz4 deleted file mode 100644 index 7af7989c260554e7a6d95533095a29605565e388..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 539 zcmV+$0_6P!B25@TK)?n8008kf0003jcmZ1&LB000625d;PR2Lb@_q>$}7 zVt8Cqr*!C-2K!He8b*$61poj54YEK008kf_V*mgE9!_azXml=5W-e4{WiCuj zVQy}3b#7y20ss4khD0dF@HPL8Nv6$fG$Wac$dok}*~e=8J$Y4(KHXI)3&N_2yah5X zLjjzANg@*SwHp4EOU+XT$a}#FhyVZp4-Wx27X=Cd0RjN=1u;1{FgZ3bGdVUnG&MLi zFgG(WI2iyk02Iv#0P_g|5p-p9bRcDJbaHthIW#ymG&leN00nje0q`;a6`I}KOOhsD zX_0;!guq#3)=X1Ajt9K8;f|5j7VxH)AvKtAAi9vdC25YT=~*;hdyrE?6-u dBByJ4^)@sl(w);F77H2N#fg(MGXekr002aVzkvV% diff --git a/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/78ed85e7471264d8ad34a251bb1ba5f4/000000000009.mdblob.lz4 deleted file mode 100644 index 2f2bab89efc2523e617d6671f392c06d6af5f79a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 219 zcmV<103`neB25@TK)}oZ0Du4h0rYt7h37{SWZ11VqEWjWrSzdve%4BAr|AGL&elnk z=ruzGlCGLDOI3C7GwSe9csDLZ868tJf7Ql7v&wAt(AEF|0000000aU5`-X-@D8}$L z|BOkd&1*CxnTp7iH5S>&YWqETRg6B}RVWL>s)@V>GA%;^oPJ3n67#hh{*+72QwGR; z!3oJn*wJw0bM%n&D~cmD?9Qp8A|WCVtK$V+S4DW?G9@8UB1OG1C9}M5+{7mZ1&LB000625eo(Y2Lb@{1u-)+ zHZwLiG%_?eFg7wbF*!CdFdqOS5()?a@KFE}bY*jNAZ2cJa(N&*GBGnSH~;_u1$Jp< zWp4lg001^k+8Tr%^ehE(u?o{F!W-UKNLAg=gH$9AIAxaTQ6(O}|KiiDc~n-J=`K02W!VDF2YdfBUZ8A8 ho~WcX)Xciy`zTY>s(8zv-l>{buTIKUH~x2u6##m}P6Yq} delta 200 zcmaFE_=d5bfq`*C#oN~B?m{Uy)|xB|+%3KI%fg_#YhGE)Uoz;OyXHCNg{d>kq;<0l zy+cbs7{B`9UtzB2Dk&RmQh)WB!{$>t-!5EZzz(>ct#{tMgnPzLvC?N(H`n}Qlgrt} z3RTJyV|v{J$eviQ>(II`;i^=xTQn diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index cda6ae95d50..8eee9f30405 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1547032376e4:infod5:filesld6:lengthi539e4:pathl23:000000000007.mdblob.lz4eed6:lengthi219e4:pathl23:000000000009.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:78ed85e7471264d8ad34a251bb1ba5f412:piece lengthi16384e6:pieces20:žHaÄó ô:®”½# Õ{|Ü·jÃee \ No newline at end of file +d13:creation datei1547048874e4:infod5:filesld6:lengthi541e4:pathl23:000000000007.mdblob.lz4eed6:lengthi211e4:pathl23:000000000009.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:e272139feb2182700852b34c8cf6d37012:piece lengthi16384e6:pieces20:’Ç[–jššÁ6ÿ^}ÖfeçÆ_,[ee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index 2b24dc0d65e2d00524eada7a706b1b06bb6a378a..31f66e52f0b24031d29299dc8f901738a669c4ee 100644 GIT binary patch delta 200 zcmaFE_=d5bfq`+tqaxw?uN9jLID$6&^nANqkoRc+`nYyBq1pvEB@QvOvJ07(v|hb! zd61o-zqs&>|KiiDc~n-J=`K02W!VDF2YdfBUP4DH(NCRQr)-PKHE3UJ#gvwfzs+Z-~a9jluQ-dB5hJyHRU!>I`7}3u*81W^(_wrWHcQ*qg71wnEkq;<0l zy+cbs7{B`9UtzB2Dk&RmQh)WB!{$>t-!5EZzz%p_X4=Od>CN_9aC)uoq^(n9o5O_s zph{U{Os`u2*%Rw^9db-(t>@gZStxkLUL_}q7}gITd5^r`yJYcK-m6j;Spg~A#gA&c iME_OUWBKi)fu8#Rw9k*WJY4d5$*k{Dx+mh=02vW05jP}!TZsNbMUWWz}eW2MG z!UF{d5;b&%)!RA22M-T(am-Jn%c~3|sx~d8z_g~IDd4^T000625dj7O2Lb@{RQ!q+ zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`Y3-%z8j)d)p3`a(5>8lP-Dz_(IuZotbPp%;Ei+^C(w-K&4`I hjo|_jhe>tDqLnA%N0g7@2Nf2Y_E~O>j|2b!003QsZ8ZP@ literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..9c033fcd50adee2b1deb542732918e3c095f604a GIT binary patch literal 541 zcmZQk@|AFKIKalp!0^$Wfq_xyQIYWc*NROA96_6XdcIvQ$a}PZeOx=6Q0;=75{H;s z*@a9?TCd)=Jjl+^UtD;`fAQ(nJSwZqbeA01vTT9ogT4P57?>CYS=boZnHWASnef&! zsUjw5dC80EY`^^*rQG^*SQ!`?csDsPFa(HCN(Qp!{4&!sO7#5G^g=RH^?WkRGV{{) zTv8KL;tdTf3<`iMSi>??QuBcfm5h>-0xNy}q!L5D#G>Sk%(7Iy{GxO#GfOi|{lvVy z{L;MSRE3SUw#ALR{T(lIFg;V+cV1cKs=_X#)gQL}=adWgwl{rpPya@8TSj*$`}IjH zuY_`~mzgLtmBHT5@w!h&gus86E}%vH{EQajtXzzYObj1c4UG(pEe%Z!%}s!`v9Y4~BfT1@0Uv4_6#~DxY7(q`JW4RpBNVhFj%F7wq!2O#9r|eB%0~4Xcm-tvc+M cu+sZ_!wmT<%ipuhW7Ms(+8O@KgQAH60Gmy{i~s-t literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..9d53ab5d2cd995bc15db088b18b481063fc318c5 GIT binary patch literal 211 zcmV;^04)CmB25@TK)}QR0Du4h0rcW>6QAoLf^Y~@vrLTk({OI$zpq}01`>Us*%-nD z1qTu}bcNO1Il%`H4|8$MPovAL3?!;HEu_G-rl2X{z5gJwJw0=QyH7#U2mmh=02vW05jP}!TZsNbMUWWz}eW2MG z!UF{d5;b&%)!RA22M-T(am-Jn%c~3|sx~d8z_g~IDd4^T000625eo(Y2Lb@{1u--- zHZwUiHZwFaH#jshF*YzUIUfKb5Df?b@K68{bY*jNAZ2cJa(N&*H#Rdc00005c4=f~ zZvX%Q0R8ExcWe^F_}C`tVBb{+I#);awV%%lSJDKA{%Sq%Pmw>TtOaV}%g2`F&nP<0 Y=K2OTRjo`i5YcJjGAb@g0RR9108BMd+yDRo literal 0 HcmV?d00001 diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index 080c41e867c..f2bdaf4ec56 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -188,6 +188,7 @@ def on_state_update(new_state): else: self.tray_icon = None + self.hide_left_menu_playlist() self.left_menu_button_debug.setHidden(True) self.top_menu_button.setHidden(True) self.left_menu.setHidden(True) @@ -505,6 +506,7 @@ def on_settings_button_click(self): self.stackedWidget.setCurrentIndex(PAGE_SETTINGS) self.settings_page.load_settings() self.navigation_stack = [] + self.hide_left_menu_playlist() def on_token_balance_click(self, _): self.raise_window() @@ -513,6 +515,7 @@ def on_token_balance_click(self, _): self.load_token_balance() self.trust_page.load_blocks() self.navigation_stack = [] + self.hide_left_menu_playlist() def load_token_balance(self): self.request_mgr = TriblerRequestManager() @@ -744,28 +747,33 @@ def clicked_menu_button_home(self): self.deselect_all_menu_buttons(self.left_menu_button_home) self.stackedWidget.setCurrentIndex(PAGE_HOME) self.navigation_stack = [] + self.hide_left_menu_playlist() def clicked_menu_button_search(self): self.deselect_all_menu_buttons(self.left_menu_button_search) self.stackedWidget.setCurrentIndex(PAGE_SEARCH_RESULTS) self.navigation_stack = [] + self.hide_left_menu_playlist() def clicked_menu_button_discovered(self): self.deselect_all_menu_buttons(self.left_menu_button_discovered) self.stackedWidget.setCurrentIndex(PAGE_DISCOVERED) self.discovered_page.load_discovered_channels() self.navigation_stack = [] + self.hide_left_menu_playlist() def clicked_menu_button_my_channel(self): self.deselect_all_menu_buttons(self.left_menu_button_my_channel) self.stackedWidget.setCurrentIndex(PAGE_EDIT_CHANNEL) self.edit_channel_page.load_my_channel_overview() self.navigation_stack = [] + self.hide_left_menu_playlist() def clicked_menu_button_video_player(self): self.deselect_all_menu_buttons(self.left_menu_button_video_player) self.stackedWidget.setCurrentIndex(PAGE_VIDEO_PLAYER) self.navigation_stack = [] + self.show_left_menu_playlist() def clicked_menu_button_downloads(self): self.deselect_all_menu_buttons(self.left_menu_button_downloads) @@ -773,6 +781,7 @@ def clicked_menu_button_downloads(self): self.left_menu_button_downloads.setChecked(True) self.stackedWidget.setCurrentIndex(PAGE_DOWNLOADS) self.navigation_stack = [] + self.hide_left_menu_playlist() def clicked_menu_button_debug(self): if not self.debug_window: @@ -784,6 +793,17 @@ def clicked_menu_button_subscriptions(self): self.subscribed_channels_page.load_subscribed_channels() self.stackedWidget.setCurrentIndex(PAGE_SUBSCRIBED_CHANNELS) self.navigation_stack = [] + self.hide_left_menu_playlist() + + def hide_left_menu_playlist(self): + self.left_menu_seperator.setHidden(True) + self.left_menu_playlist_label.setHidden(True) + self.left_menu_playlist.setHidden(True) + + def show_left_menu_playlist(self): + self.left_menu_seperator.setHidden(False) + self.left_menu_playlist_label.setHidden(False) + self.left_menu_playlist.setHidden(False) def on_channel_clicked(self, public_key): self.channel_page.initialize_with_channel(public_key) From a27fa4252a26cc2726a0d67587cdf1654e4b685c Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Sun, 23 Dec 2018 16:30:08 +0100 Subject: [PATCH 05/38] Loading metadata store before gigachannel overlay --- Tribler/Core/APIImplementation/LaunchManyCore.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 8429ac70081..d35fb231c85 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -451,6 +451,13 @@ def init(self): except ImportError: self._logger.error("bitcoinlib library cannot be found, Bitcoin wallet not available!") + if self.session.config.get_chant_enabled(): + channels_dir = os.path.join(self.session.config.get_chant_channels_dir()) + database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'metadata.db') + self.mds = MetadataStore(database_path, channels_dir, self.session.trustchain_keypair) + self.gigachannel_manager = GigaChannelManager(self.session) + self.gigachannel_manager.start() + if self.session.config.get_dummy_wallets_enabled(): # For debugging purposes, we create dummy wallets dummy_wallet1 = DummyWallet1() @@ -516,13 +523,6 @@ def init(self): self.version_check_manager = VersionCheckManager(self.session) self.version_check_manager.start() - if self.session.config.get_chant_enabled(): - channels_dir = os.path.join(self.session.config.get_chant_channels_dir()) - database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'metadata.db') - self.mds = MetadataStore(database_path, channels_dir, self.session.trustchain_keypair) - self.gigachannel_manager = GigaChannelManager(self.session) - self.gigachannel_manager.start() - self.session.set_download_states_callback(self.sesscb_states_callback) if self.session.config.get_ipv8_enabled() and self.session.config.get_trustchain_enabled(): From f8f1b0acdff9501f99a44a6a8bb77ff5966cbf82 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Thu, 3 Jan 2019 10:43:16 +0100 Subject: [PATCH 06/38] Gigachannel GUI refactor and polish --- .../Core/Modules/restapi/search_endpoint.py | 17 +- Tribler/Core/Modules/restapi/util.py | 7 - Tribler/Test/GUI/test_gui.py | 271 ++- TriblerGUI/defs.py | 15 + TriblerGUI/qt_resources/channel_view.ui | 1493 ----------------- TriblerGUI/qt_resources/mainwindow.ui | 706 +++++++- .../torrent_channel_list_container.ui | 458 ----- .../qt_resources/torrent_details_container.ui | 432 +++++ TriblerGUI/qt_resources/torrents_list.ui | 181 ++ TriblerGUI/tribler_window.py | 14 +- TriblerGUI/utilities.py | 6 + TriblerGUI/widgets/channelpage.py | 43 +- TriblerGUI/widgets/channelview.py | 446 ----- TriblerGUI/widgets/createtorrentpage.py | 15 +- TriblerGUI/widgets/discoveredpage.py | 23 +- TriblerGUI/widgets/editchannelpage.py | 230 ++- TriblerGUI/widgets/home_recommended_item.py | 9 +- TriblerGUI/widgets/homepage.py | 11 +- TriblerGUI/widgets/lazytableview.py | 707 ++------ TriblerGUI/widgets/searchresultspage.py | 82 +- TriblerGUI/widgets/subscribedchannelspage.py | 54 +- TriblerGUI/widgets/subscriptionswidget.py | 41 +- TriblerGUI/widgets/tabbuttonpanel.py | 6 + TriblerGUI/widgets/tablecontentdelegate.py | 513 ++++++ TriblerGUI/widgets/tablecontentmodel.py | 225 +++ TriblerGUI/widgets/tableiconbuttons.py | 66 + TriblerGUI/widgets/torrentdetailscontainer.py | 13 + TriblerGUI/widgets/torrentdetailstabwidget.py | 11 +- TriblerGUI/widgets/torrentslistwidget.py | 38 + TriblerGUI/widgets/triblertablecontrollers.py | 252 +++ 30 files changed, 3026 insertions(+), 3359 deletions(-) delete mode 100644 TriblerGUI/qt_resources/channel_view.ui delete mode 100644 TriblerGUI/qt_resources/torrent_channel_list_container.ui create mode 100644 TriblerGUI/qt_resources/torrent_details_container.ui create mode 100644 TriblerGUI/qt_resources/torrents_list.ui delete mode 100644 TriblerGUI/widgets/channelview.py create mode 100644 TriblerGUI/widgets/tablecontentdelegate.py create mode 100644 TriblerGUI/widgets/tablecontentmodel.py create mode 100644 TriblerGUI/widgets/tableiconbuttons.py create mode 100644 TriblerGUI/widgets/torrentdetailscontainer.py create mode 100644 TriblerGUI/widgets/torrentslistwidget.py create mode 100644 TriblerGUI/widgets/triblertablecontrollers.py diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 597da10e908..a1aecc4ef01 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -89,11 +89,7 @@ def render_GET(self, request): ], "chant_dirty":false } - """ - - - first = 0 last = None item_type = None @@ -107,9 +103,6 @@ def render_GET(self, request): channel = None xxx_filter = self.session.config.get_family_filter_enabled() - if 'xxx_filter' in request.args and request.args['xxx_filter'] > 0 \ - and request.args['xxx_filter'][0] == "1": - xxx_filter = False if 'first' in request.args and request.args['first'] > 0: first = int(request.args['first'][0]) @@ -125,12 +118,8 @@ def render_GET(self, request): if 'sort_by' in request.args and request.args['sort_by'] > 0: sort_by = request.args['sort_by'][0] - if sort_by.startswith(u'-'): - sort_forward = False - sort_column = sort_by[1:] - else: - sort_forward = True - sort_column = sort_by + sort_forward = True #TODO(Martijn): fix correctly + sort_column = sort_by if 'txt' in request.args and request.args['txt'] > 0: txt_search_query = request.args['txt'][0] @@ -199,7 +188,7 @@ def render_GET(self, request): results.extend(pony_query_results) # Legacy query for subscribed channels - skip_dispersy = not txt_search_query or (channel and not is_dispersy_channel) + skip_dispersy = txt_search_query or (channel and not is_dispersy_channel) if subscribed: skip_dispersy = True subscribed_channels_db = self.channel_db_handler.getMySubscribedChannels(include_dispersy=True) diff --git a/Tribler/Core/Modules/restapi/util.py b/Tribler/Core/Modules/restapi/util.py index 6292fb71a19..e5e7d4a0368 100644 --- a/Tribler/Core/Modules/restapi/util.py +++ b/Tribler/Core/Modules/restapi/util.py @@ -15,13 +15,6 @@ from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, time2int, int2time from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE -HEALTH_CHECKING = u'Checking..' -HEALTH_DEAD = u'No peers' -HEALTH_ERROR = u'Error' -HEALTH_MOOT = u'Peers found' -HEALTH_GOOD = u'Seeds found' -HEALTH_UNCHECKED = u'Unknown' - CATEGORY_OLD_CHANNEL = u'Old channel' CATEGORY_CHANNEL = u'Channel' CATEGORY_UNKNOWN = u'Unknown' diff --git a/Tribler/Test/GUI/test_gui.py b/Tribler/Test/GUI/test_gui.py index 8638e490223..5be0aaa4abe 100644 --- a/Tribler/Test/GUI/test_gui.py +++ b/Tribler/Test/GUI/test_gui.py @@ -1,35 +1,34 @@ from __future__ import absolute_import + import logging import os import sys import threading -from random import randint +import time import unittest -from unittest import skipUnless, skipIf +from unittest import skipIf, skipUnless -import time -from PyQt5.QtCore import QPoint, Qt, QTimer +from PyQt5.QtCore import QPoint, QTimer, Qt from PyQt5.QtGui import QPixmap, QRegion from PyQt5.QtTest import QTest -from PyQt5.QtWidgets import QApplication, QListWidget, QTreeWidget, QTextEdit +from PyQt5.QtWidgets import QApplication, QListWidget, QTableView, QTextEdit, QTreeWidget from six.moves import xrange +from Tribler.Core.Utilities.network_utils import get_random_port + +import TriblerGUI import TriblerGUI.core_manager as core_manager import TriblerGUI.defs -from Tribler.Core.Utilities.network_utils import get_random_port from TriblerGUI.dialogs.feedbackdialog import FeedbackDialog +from TriblerGUI.tribler_window import TriblerWindow from TriblerGUI.widgets.home_recommended_item import HomeRecommendedItem +from TriblerGUI.widgets.loading_list_item import LoadingListItem api_port = get_random_port() core_manager.START_FAKE_API = True - -import TriblerGUI TriblerGUI.defs.DEFAULT_API_PORT = api_port -from TriblerGUI.widgets.loading_list_item import LoadingListItem -from TriblerGUI.tribler_window import TriblerWindow - if os.environ.get("TEST_GUI") == "yes": app = QApplication(sys.argv) window = TriblerWindow(api_port=api_port) @@ -141,6 +140,8 @@ def wait_for_list_populated(self, llist, num_items=1, timeout=10): elif isinstance(llist, QTreeWidget) and llist.topLevelItemCount() >= num_items: if not isinstance(llist.topLevelItem(0), LoadingListItem): return + elif isinstance(llist, QTableView) and llist.verticalHeader().count() >= num_items: + return # List was not populated in time, fail the test raise TimeoutException("The list was not populated within 10 seconds") @@ -206,6 +207,11 @@ def wait_for_qtext_edit_populated(self, qtext_edit, timeout=10): # QTextEdit was not populated in time, fail the test raise TimeoutException("QTextEdit was not populated within 10 seconds") + def get_index_of_row(self, table_view, row): + x = table_view.columnViewportPosition(0) + y = table_view.rowViewportPosition(row) + return table_view.indexAt(QPoint(x, y)) + @skipUnless(os.environ.get("TEST_GUI") == "yes", "Not testing the GUI by default") class TriblerGUITest(AbstractTriblerGUITest): @@ -233,12 +239,67 @@ def test_subscriptions(self): self.wait_for_list_populated(window.subscribed_channels_list) self.screenshot(window, name="subscriptions") - first_widget = window.subscribed_channels_list.itemWidget(window.subscribed_channels_list.item(0)) - QTest.mouseClick(first_widget.subscribe_button, Qt.LeftButton) - self.wait_for_signal(first_widget.subscriptions_widget.unsubscribed_channel) + # Sort + window.subscribed_channels_list.sortByColumn(1, 1) + self.wait_for_list_populated(window.subscribed_channels_list) + self.screenshot(window, name="subscriptions_sorted") + max_items = min(window.subscribed_channels_list.model().total_items, 50) + self.assertLessEqual(window.subscribed_channels_list.verticalHeader().count(), max_items) + + # Filter + old_num_items = window.subscribed_channels_list.verticalHeader().count() + QTest.keyClick(window.subscribed_channels_filter_input, '1') + self.wait_for_list_populated(window.subscribed_channels_list) + self.screenshot(window, name="subscriptions_filtered") + self.assertLessEqual(window.subscribed_channels_list.verticalHeader().count(), old_num_items) + window.subscribed_channels_filter_input.setText('') + self.wait_for_list_populated(window.subscribed_channels_list) + + # Unsubscribe and subscribe again + index = self.get_index_of_row(window.subscribed_channels_list, 0) + window.subscribed_channels_list.on_subscribe_control_clicked(index) + self.wait_for_signal(window.subscribed_channels_list.on_unsubscribed_channel) self.screenshot(window, name="unsubscribed") - QTest.mouseClick(first_widget.subscribe_button, Qt.LeftButton) - self.wait_for_signal(first_widget.subscriptions_widget.subscribed_channel) + + window.subscribed_channels_list.on_subscribe_control_clicked(index) + self.wait_for_signal(window.subscribed_channels_list.on_subscribed_channel) + + def test_discovered_page(self): + QTest.mouseClick(window.left_menu_button_discovered, Qt.LeftButton) + self.wait_for_list_populated(window.discovered_channels_list) + self.screenshot(window, name="discovered_page") + + # Sort + window.discovered_channels_list.sortByColumn(1, 1) + self.wait_for_list_populated(window.discovered_channels_list) + self.screenshot(window, name="discovered_sorted") + max_items = min(window.discovered_channels_list.model().total_items, 50) + self.assertLessEqual(window.discovered_channels_list.verticalHeader().count(), max_items) + + # Filter + old_num_items = window.discovered_channels_list.verticalHeader().count() + QTest.keyClick(window.discovered_channels_filter_input, '1') + self.wait_for_list_populated(window.discovered_channels_list) + self.screenshot(window, name="discovered_filtered") + self.assertLessEqual(window.discovered_channels_list.verticalHeader().count(), old_num_items) + + def test_channel_torrents(self): + QTest.mouseClick(window.left_menu_button_subscriptions, Qt.LeftButton) + self.wait_for_list_populated(window.subscribed_channels_list) + index = self.get_index_of_row(window.subscribed_channels_list, 0) + window.subscribed_channels_list.on_table_item_clicked(index) + self.wait_for_list_populated(window.channel_page_container.content_table) + self.screenshot(window, name="channel_torrents_loaded") + + # Toggle credit mining + QTest.mouseClick(window.credit_mining_button, Qt.LeftButton) + self.wait_for_signal(window.subscription_widget.credit_mining_toggled) + + # Click the first torrent + index = self.get_index_of_row(window.channel_page_container.content_table, 0) + window.channel_page_container.content_table.on_table_item_clicked(index) + QTest.qWait(100) + self.screenshot(window, name="channel_overview_details") def test_edit_channel_overview(self): QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) @@ -258,53 +319,55 @@ def test_edit_channel_torrents(self): self.wait_for_variable("edit_channel_page.channel_overview") QTest.mouseClick(window.edit_channel_torrents_button, Qt.LeftButton) self.screenshot(window, name="edit_channel_torrents_loading") - self.wait_for_list_populated(window.edit_channel_torrents_list) + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) self.screenshot(window, name="edit_channel_torrents") - first_widget = window.edit_channel_torrents_list.itemWidget(window.edit_channel_torrents_list.item(0)) - QTest.mouseClick(first_widget, Qt.LeftButton) - self.screenshot(window, name="edit_channel_torrents_selected") - QTest.mouseClick(window.edit_channel_torrents_remove_selected_button, Qt.LeftButton) - self.screenshot(window, name="remove_channel_torrent_dialog") - QTest.mouseClick(window.edit_channel_page.dialog.buttons[1], Qt.LeftButton) - - QTest.mouseClick(window.edit_channel_torrents_remove_all_button, Qt.LeftButton) - self.screenshot(window, name="remove_all_channel_torrent_dialog") - QTest.mouseClick(window.edit_channel_page.dialog.buttons[1], Qt.LeftButton) + # Sort + window.edit_channel_torrents_container.content_table.sortByColumn(2, 1) # Size + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) + self.screenshot(window, name="edit_channel_torrents_sorted") + max_items = min(window.discovered_channels_list.model().total_items, 50) + self.assertLessEqual(window.discovered_channels_list.verticalHeader().count(), max_items) + + # Filter + old_num_items = window.edit_channel_torrents_container.content_table.verticalHeader().count() + QTest.keyClick(window.edit_channel_torrents_filter, 'a') + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) + self.screenshot(window, name="edit_channel_torrents_filtered") + self.assertLessEqual(window.edit_channel_torrents_container.content_table.verticalHeader().count(), + old_num_items) + window.edit_channel_torrents_filter.setText('') + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) + + # Remove a single torrent + index = self.get_index_of_row(window.edit_channel_torrents_container.content_table, 0) + window.edit_channel_torrents_container.content_table.setCurrentIndex(index) + QTest.mouseClick(window.remove_selected_button, Qt.LeftButton) + self.screenshot(window, name="edit_channel_remove_torrent_dialog") + QTest.mouseClick(window.edit_channel_page.dialog.buttons[0], Qt.LeftButton) + self.wait_for_signal(window.edit_channel_page.on_torrents_removed) - def test_edit_channel_playlists(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_playlists_button, Qt.LeftButton) - self.screenshot(window, name="edit_channel_playlists_loading") - self.wait_for_list_populated(window.edit_channel_playlists_list) - self.screenshot(window, name="edit_channel_playlists") + # Remove all torrents + QTest.mouseClick(window.remove_all_button, Qt.LeftButton) + self.screenshot(window, name="edit_channel_remove_all_dialog") + QTest.mouseClick(window.edit_channel_page.dialog.buttons[0], Qt.LeftButton) + self.wait_for_signal(window.edit_channel_page.on_all_torrents_removed, no_args=True) + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) + self.screenshot(window, name="edit_channel_remove_all_pending") - def test_edit_channel_rssfeeds(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_rss_feeds_button, Qt.LeftButton) - self.screenshot(window, name="edit_channel_rssfeeds_loading") - self.wait_for_list_populated(window.edit_channel_rss_feeds_list) - self.screenshot(window, name="edit_channel_rssfeeds") + # Commit the result + QTest.mouseClick(window.edit_channel_commit_button, Qt.LeftButton) + self.wait_for_signal(window.edit_channel_page.on_commit, no_args=True) + self.screenshot(window, name="edit_channel_committed") - def test_add_remove_refresh_rssfeed(self): + def test_create_torrent(self): QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_rss_feeds_button, Qt.LeftButton) - self.wait_for_list_populated(window.edit_channel_rss_feeds_list) - QTest.mouseClick(window.edit_channel_details_rss_add_button, Qt.LeftButton) - self.screenshot(window, name="edit_channel_add_rssfeeds_dialog") - window.edit_channel_page.dialog.dialog_widget.dialog_input.setText("http://test.com/rss.xml") - QTest.mouseClick(window.edit_channel_page.dialog.buttons[0], Qt.LeftButton) - - # Remove item - window.edit_channel_rss_feeds_list.topLevelItem(0).setSelected(True) - QTest.mouseClick(window.edit_channel_details_rss_feeds_remove_selected_button, Qt.LeftButton) - self.screenshot(window, name="edit_channel_remove_rssfeeds_dialog") - QTest.mouseClick(window.edit_channel_page.dialog.buttons[0], Qt.LeftButton) - - QTest.mouseClick(window.edit_channel_details_rss_refresh_button, Qt.LeftButton) + QTest.mouseClick(window.edit_channel_torrents_button, Qt.LeftButton) + self.wait_for_list_populated(window.edit_channel_torrents_container.content_table) + window.edit_channel_page.on_create_torrent_from_files() + self.screenshot(window, name="create_torrent_page") + QTest.mouseClick(window.manage_channel_create_torrent_back, Qt.LeftButton) def test_settings(self): QTest.mouseClick(window.settings_button, Qt.LeftButton) @@ -366,7 +429,7 @@ def test_search_suggestions(self): def test_search(self): window.top_search_bar.setText("trib") QTest.keyClick(window.top_search_bar, Qt.Key_Enter) - self.wait_for_list_populated(window.search_results_list, num_items=20) + self.wait_for_list_populated(window.search_results_list) self.screenshot(window, name="search_results_all") QTest.mouseClick(window.search_results_channels_button, Qt.LeftButton) @@ -376,64 +439,6 @@ def test_search(self): self.wait_for_list_populated(window.search_results_list) self.screenshot(window, name="search_results_torrents") - def test_channel_playlist(self): - QTest.mouseClick(window.left_menu_button_subscriptions, Qt.LeftButton) - self.wait_for_list_populated(window.subscribed_channels_list) - first_widget = window.subscribed_channels_list.itemWidget(window.subscribed_channels_list.item(0)) - QTest.mouseClick(first_widget, Qt.LeftButton) - self.screenshot(window, name="channel_loading") - self.wait_for_list_populated(window.channel_torrents_list) - self.screenshot(window, name="channel") - - first_widget = window.channel_torrents_list.itemWidget(window.channel_torrents_list.item(0)) - QTest.mouseClick(first_widget, Qt.LeftButton) - self.screenshot(window, name="channel_playlist") - - def test_create_remove_playlist(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_playlists_button, Qt.LeftButton) - self.wait_for_list_populated(window.edit_channel_playlists_list) - old_count = window.edit_channel_playlists_list.count() - QTest.mouseClick(window.edit_channel_create_playlist_button, Qt.LeftButton) - self.screenshot(window, "create_playlist") - - # Create playlist - window.playlist_edit_name.setText("Unit test playlist") - window.playlist_edit_description.setText("Unit test playlist description") - QTest.mouseClick(window.playlist_edit_save_button, Qt.LeftButton) - self.wait_for_signal(window.edit_channel_page.playlists_loaded) - self.assertEqual(old_count + 1, window.edit_channel_playlists_list.count()) - - # Remove playlist - last_widget = window.edit_channel_playlists_list.itemWidget(window.edit_channel_playlists_list.item(old_count)) - QTest.mouseClick(last_widget.remove_playlist_button, Qt.LeftButton) - self.screenshot(window, name="remove_playlist_dialog") - QTest.mouseClick(window.edit_channel_page.dialog.buttons[0], Qt.LeftButton) - self.wait_for_signal(window.edit_channel_page.playlists_loaded) - self.assertEqual(old_count, window.edit_channel_playlists_list.count()) - - def test_edit_playlist(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_playlists_button, Qt.LeftButton) - self.wait_for_list_populated(window.edit_channel_playlists_list) - - first_widget = window.edit_channel_playlists_list.itemWidget(window.edit_channel_playlists_list.item(0)) - QTest.mouseClick(first_widget.edit_playlist_button, Qt.LeftButton) - self.screenshot(window, name="edit_playlist") - - rand_name = "Random name %d" % randint(1, 1000) - rand_desc = "Random description %d" % randint(1, 1000) - - window.playlist_edit_name.setText(rand_name) - window.playlist_edit_description.setText(rand_desc) - QTest.mouseClick(window.playlist_edit_save_button, Qt.LeftButton) - self.wait_for_signal(window.edit_channel_page.playlists_loaded) - - first_widget = window.edit_channel_playlists_list.itemWidget(window.edit_channel_playlists_list.item(0)) - self.assertEqual(first_widget.playlist_name.text(), rand_name) - def test_add_download_url(self): window.on_add_torrent_from_url() self.go_to_and_wait_for_downloads() @@ -504,11 +509,6 @@ def on_report_sent(response): QTimer.singleShot(1000, screenshot_dialog) dialog.exec_() - def test_discovered_page(self): - QTest.mouseClick(window.left_menu_button_discovered, Qt.LeftButton) - self.wait_for_list_populated(window.discovered_channels_list) - self.screenshot(window, name="discovered_page") - def test_debug_pane(self): self.wait_for_variable("tribler_settings") QTest.mouseClick(window.settings_button, Qt.LeftButton) @@ -605,37 +605,6 @@ def test_debug_pane(self): window.debug_window.close() - def test_create_torrent(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_torrents_button, Qt.LeftButton) - self.wait_for_list_populated(window.edit_channel_torrents_list) - window.edit_channel_page.on_create_torrent_from_files() - self.screenshot(window, name="create_torrent_page") - QTest.mouseClick(window.manage_channel_create_torrent_back, Qt.LeftButton) - - def test_manage_playlist(self): - QTest.mouseClick(window.left_menu_button_my_channel, Qt.LeftButton) - self.wait_for_variable("edit_channel_page.channel_overview") - QTest.mouseClick(window.edit_channel_playlists_button, Qt.LeftButton) - self.wait_for_list_populated(window.edit_channel_playlists_list) - first_widget = window.edit_channel_playlists_list.itemWidget(window.edit_channel_playlists_list.item(0)) - QTest.mouseClick(first_widget, Qt.LeftButton) - QTest.mouseClick(window.edit_channel_playlist_manage_torrents_button, Qt.LeftButton) - self.wait_for_list_populated(window.playlist_manage_in_playlist_list) - self.screenshot(window, name="manage_playlist_before") - - # Swap the first item of the lists around - window.playlist_manage_in_playlist_list.setCurrentRow(0) - QTest.mouseClick(window.playlist_manage_remove_from_playlist, Qt.LeftButton) - - window.playlist_manage_in_channel_list.setCurrentRow(0) - QTest.mouseClick(window.playlist_manage_add_to_playlist, Qt.LeftButton) - - self.screenshot(window, name="manage_playlist_after") - - QTest.mouseClick(window.edit_channel_manage_playlist_save_button, Qt.LeftButton) - def test_trust_page(self): QTest.mouseClick(window.token_balance_widget, Qt.LeftButton) self.wait_for_variable("trust_page.blocks") diff --git a/TriblerGUI/defs.py b/TriblerGUI/defs.py index 6c5321640e5..e58793d884d 100644 --- a/TriblerGUI/defs.py +++ b/TriblerGUI/defs.py @@ -93,5 +93,20 @@ # Garbage collection timer (in minutes) GC_TIMEOUT = 10 +ACTION_BUTTONS = u'action_buttons' + +# Torrent commit status constants +COMMIT_STATUS_NEW = 0 +COMMIT_STATUS_TODELETE = 1 +COMMIT_STATUS_COMMITTED = 2 + + +HEALTH_CHECKING = u'Checking..' +HEALTH_DEAD = u'No peers' +HEALTH_ERROR = u'Error' +HEALTH_MOOT = u'Peers found' +HEALTH_GOOD = u'Seeds found' +HEALTH_UNCHECKED = u'Unknown' + # Interval for refreshing the results in the debug pane DEBUG_PANE_REFRESH_TIMEOUT = 5000 # 5 seconds diff --git a/TriblerGUI/qt_resources/channel_view.ui b/TriblerGUI/qt_resources/channel_view.ui deleted file mode 100644 index 43043823975..00000000000 --- a/TriblerGUI/qt_resources/channel_view.ui +++ /dev/null @@ -1,1493 +0,0 @@ - - - channel_view - - - - 0 - 0 - 830 - 536 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - background-color: #202020; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - QSplitter::handle { background-color: #555; } - - - Qt::Horizontal - - - - - 0 - 0 - - - - - - - - - 0 - 0 - 0 - - - - - - - 32 - 32 - 32 - - - - - - - 255 - 255 - 255 - - - - - - - 246 - 246 - 245 - - - - - - - 119 - 119 - 118 - - - - - - - 159 - 159 - 157 - - - - - - - 0 - 0 - 0 - - - - - - - 255 - 255 - 255 - - - - - - - 0 - 0 - 0 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 0 - 0 - 0 - - - - - - - 246 - 246 - 245 - - - - - - - 255 - 255 - 220 - - - - - - - 0 - 0 - 0 - - - - - - - - - 0 - 0 - 0 - - - - - - - 32 - 32 - 32 - - - - - - - 255 - 255 - 255 - - - - - - - 246 - 246 - 245 - - - - - - - 119 - 119 - 118 - - - - - - - 159 - 159 - 157 - - - - - - - 0 - 0 - 0 - - - - - - - 255 - 255 - 255 - - - - - - - 0 - 0 - 0 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 0 - 0 - 0 - - - - - - - 246 - 246 - 245 - - - - - - - 255 - 255 - 220 - - - - - - - 0 - 0 - 0 - - - - - - - - - 119 - 119 - 118 - - - - - - - 32 - 32 - 32 - - - - - - - 255 - 255 - 255 - - - - - - - 246 - 246 - 245 - - - - - - - 119 - 119 - 118 - - - - - - - 159 - 159 - 157 - - - - - - - 119 - 119 - 118 - - - - - - - 255 - 255 - 255 - - - - - - - 119 - 119 - 118 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 0 - 0 - 0 - - - - - - - 238 - 238 - 236 - - - - - - - 255 - 255 - 220 - - - - - - - 0 - 0 - 0 - - - - - - - - ArrowCursor - - - Form - - - false - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - - 0 - 80 - - - - - 0 - 50 - - - - - QLayout::SetDefaultConstraint - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - 0 - 0 - - - - - 200 - 0 - - - - QLineEdit { -background-color: #eee; -border: none; -padding-left: 5px; -border-radius: 14px; -color: black; -} - - - - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - false - - - - - - - - - false - - - color: #B5B5B5; - - - Your channel has uncommitted changes. - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - true - - - - 0 - 0 - - - - - 0 - 35 - - - - - - - - - 32 - 32 - 32 - - - - - - - 186 - 189 - 182 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 186 - 189 - 182 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - - 32 - 32 - 32 - - - - - - - 190 - 190 - 190 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - - Apply changes to your channel and publish the new version - - - false - - - Apply changes - - - false - - - false - - - - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 20 - - - - - - - - - - - - - - - 0 - 0 - - - - - 500 - 50 - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 85 - 87 - 83 - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 85 - 87 - 83 - - - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 32 - 32 - 32 - - - - - - - 145 - 145 - 145 - - - - - - - - QTableView { - border: none; - font-size: 13px; - outline: 0; - } - QTableView::item { - color: white; - height: 40px; - border-bottom: 1px solid #303030; - } - - -QTableView::item::hover { - background-color: rgba(255,255,255, 50); - } - - QHeaderView { - background-color: transparent; - } - QHeaderView::section { - background-color: transparent; - border: none; - color: #B5B5B5; - padding: 10px; - font-size: 14px; - border-bottom: 1px solid #303030; - } - QHeaderView::section:hover { - color: white; - } - QTableCornerButton::section { - background-color: transparent; - } - QHeaderView::section:up-arrow { - color: white; - } - QHeaderView::section:down-arrow { - color: white; - } - - - - - - - - - 16777215 - 50 - - - - - 8 - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE SELECTED - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE ALL - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - ADD - - - - - - - - - - - - 0 - 0 - - - - QLabel { -color: white; -} -QTabWidget { -border: none; -background-color: #202020; -} -QTabBar::tab { - color: white; - background-color: #555; -} -QTabBar::tab:selected { - color: #555; - background-color: #777; -} - - - 0 - - - - background-color: #202020; - - - Details - - - - 0 - - - 12 - - - 12 - - - 12 - - - 12 - - - - - border: none; - - - true - - - - - 0 - 0 - 186 - 469 - - - - - Qt::AlignCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; - - - Name - - - - - - - - - - true - - - - - - - font-weight: bold; - - - Category - - - - - - - font-weight: bold; - - - Size - - - - - - - - - - - - - - - - - - - - - font-weight: bold; margin-top:5px - - - Health - - - - - - - Qt::AlignCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - - - - - - - - PointingHandCursor - - - - EllipseButton{ - border: 1px solid #b5b5b5; - border-radius: 4px; - color: white - } - EllipseButton::hover{ - color: #333; - background-color:#c5c5c5; - } - - - - Re-check - - - - - - - - - - - - - - Files - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - QAbstractItemView::NoSelection - - - 0 - - - 300 - - - false - - - true - - - - PATH - - - - - SIZE - - - - - - - - - Trackers - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - 0 - - - - NAME - - - - - - - - - - - - - - EllipseButton - QToolButton -
TriblerGUI.widgets.ellipsebutton.h
-
- - TorrentDetailsTabWidget - QTabWidget -
TriblerGUI.widgets.torrentdetailstabwidget.h
- 1 -
- - ChannelsTableView - QWidget -
TriblerGUI.widgets.lazytableview.h
-
-
- - -
diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 5f1b1c12cab..76073dc793f 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -48,6 +48,46 @@ background-color: red; } QStatusBar::item { border: 0px solid black; +} +QTableView { +border: none; +font-size: 13px; +outline: 0; +} +QTableView::item::hover { +background-color: rgba(255,255,255, 50); +} +QTableView::item { +color: white; +height: 40px; +border-bottom: 1px solid #303030; +} + +QHeaderView { +background-color: transparent; +} +QHeaderView::section { +background-color: transparent; +border: none; +color: #B5B5B5; +padding: 10px; +font-size: 14px; +border-bottom: 1px solid #303030; +} +QHeaderView::section:hover { +color: white; +} +QTableCornerButton::section { +background-color: transparent; +} +QHeaderView::section:up-arrow { +color: white; +} +QHeaderView::section:down-arrow { +color: white; +} +QHeaderView { +qproperty-defaultAlignment: AlignLeft; } @@ -1050,7 +1090,7 @@ background-color: #e67300; - 14 + 2 @@ -1850,7 +1890,7 @@ font-size: 14px; 1 - 3 + 2 @@ -2300,7 +2340,355 @@ font-size: 14px; 0 - + + + + 0 + 0 + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + font-size: 14px; background-color: #cc6600; +color: #eee; + + + Your channel has uncommitted changes. + + + Qt::AlignCenter + + + + + + + + 0 + 50 + + + + + 16777215 + 50 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + + + 0 items + + + + + + + Qt::Horizontal + + + QSizePolicy::Expanding + + + + 10 + 20 + + + + + + + + + 0 + 0 + + + + + 180 + 28 + + + + + 180 + 28 + + + + + 200 + 0 + + + + QLineEdit { +border-radius: 3px; +} +QLineEdit:focus, QLineEdit::hover { +background-color: #404040; +color: white; +} + + + + + + Filter + + + + + + + Qt::Horizontal + + + QSizePolicy::Maximum + + + + 16 + 20 + + + + + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + APPLY CHANGES + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + + + + + + + 0 + 50 + + + + + 16777215 + 50 + + + + + 8 + + + 8 + + + 8 + + + 8 + + + + + Qt::Horizontal + + + + 390 + 20 + + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE SELECTED + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE ALL + + + + + + + + 0 + 0 + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + ADD + + + + + @@ -2870,7 +3258,7 @@ font-weight: bold; color: #B5B5B5; - - results + 0 results @@ -3052,13 +3440,86 @@ color: #B5B5B5; - - - - 0 - 0 - - + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QSplitter::handle { background-color: #555; } + + + Qt::Vertical + + + false + + + false + + + + Qt::ScrollBarAlwaysOff + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + false + + + false + + + + + + 0 + 0 + + + + + 0 + 200 + + + + + 16777215 + 200 + + + + + + @@ -3246,6 +3707,56 @@ font-weight: bold; + + + + + 0 + 0 + + + + + 180 + 28 + + + + + 180 + 28 + + + + QLineEdit { +border-radius: 3px; +} +QLineEdit:focus, QLineEdit::hover { +background-color: #404040; +color: white; +} + + + Filter + + + + + + + Qt::Horizontal + + + QSizePolicy::Minimum + + + + 16 + 20 + + + + @@ -3446,7 +3957,7 @@ background-color: transparent; - + @@ -6109,7 +6620,7 @@ font-size: 12px; - + 0 0 @@ -6138,6 +6649,34 @@ margin: 10px; + + + + + 180 + 28 + + + + + 180 + 28 + + + + QLineEdit { +border-radius: 3px; +} +QLineEdit:focus, QLineEdit::hover { +background-color: #404040; +color: white; +} + + + filter + + + @@ -6155,32 +6694,24 @@ margin: 10px; - - - - 42 - 24 - - - - - 42 - 24 - - - - PointingHandCursor + + + + 0 + 0 + - + font-size: 14px; +color: #B5B5B5; - ADD + 0 items - + Qt::Horizontal @@ -6199,19 +6730,28 @@ margin: 10px; - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -border-top: 1px solid #555; -} + + + Qt::ScrollBarAlwaysOff + + + QAbstractItemView::NoSelection + + + false + + + true + + + false + + false + + + false + @@ -7890,6 +8430,34 @@ margin: 10px;
+ + + + + 180 + 28 + + + + + 180 + 28 + + + + QLineEdit { +border-radius: 3px; +} +QLineEdit:focus, QLineEdit::hover { +background-color: #404040; +color: white; +} + + + filter + + + @@ -7943,7 +8511,35 @@ color: #B5B5B5; - + + + Qt::ScrollBarAlwaysOff + + + QAbstractItemView::NoSelection + + + false + + + true + + + false + + + false + + + false + + + false + + + false + + @@ -11199,7 +11795,7 @@ color: #eee; font-size: 14px; - Transaction in process, Please don't close Tribler. + Transaction in progress, Please don't close Tribler. Qt::AlignCenter @@ -11383,9 +11979,25 @@ color: #eee; 1 - ChannelViewWidget + TorrentsListWidget QWidget -
TriblerGUI.widgets.channelview.h
+
TriblerGUI.widgets.torrentslistwidget.h
+
+ + ChannelsTableView + QTableView +
TriblerGUI.widgets.lazytableview.h
+
+ + TorrentDetailsContainer + QWidget +
TriblerGUI.widgets.torrentdetailscontainer.h
+ 1 +
+ + SearchResultsTableView + QTableView +
TriblerGUI.widgets.lazytableview.h
TokenMiningPage diff --git a/TriblerGUI/qt_resources/torrent_channel_list_container.ui b/TriblerGUI/qt_resources/torrent_channel_list_container.ui deleted file mode 100644 index 4e12dc446bf..00000000000 --- a/TriblerGUI/qt_resources/torrent_channel_list_container.ui +++ /dev/null @@ -1,458 +0,0 @@ - - - torrents_channels_container - - - - 0 - 0 - 830 - 536 - - - - - 0 - 0 - - - - - 0 - 0 - - - - - 16777215 - 16777215 - - - - ArrowCursor - - - Form - - - false - - - background-color: #202020; - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - - 0 - 0 - - - - QSplitter::handle { background-color: #555; } - - - Qt::Horizontal - - - - QListWidget::item:hover { -background-color: #303030; -} -QListWidget::item:selected { -background-color: #404040; -} -QListWidget::item { -border-bottom: 1px solid #303030; -} -QListWidget { -border: none; -border-top: 1px solid #555; -background-color: #202020; -} - - - - - QLabel { -color: white; -} -QTabWidget { -border: none; -background-color: #202020; -} -QTabBar::tab { - color: white; - background-color: #555; -} -QTabBar::tab:selected { - color: #555; - background-color: #777; -} - - - 0 - - - - background-color: #202020; - - - Details - - - - 0 - - - 12 - - - 12 - - - 12 - - - 12 - - - - - border: none; - - - true - - - - - 0 - 0 - 806 - 239 - - - - - Qt::AlignCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - font-weight: bold; - - - Name - - - - - - - - - - - - - - font-weight: bold; - - - Category - - - - - - - font-weight: bold; - - - Size - - - - - - - - - - - - - - - - - - - - - font-weight: bold; margin-top:5px - - - Health - - - - - - - - Qt::AlignCenter - - - Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop - - - - - - - - - - - - Re-check - - - - EllipseButton{ - border: 1px solid #b5b5b5; - border-radius: 4px; - color: white - } - EllipseButton::hover{ - color: #333; - background-color:#c5c5c5; - } - - - - PointingHandCursor - - - - - - - - - - - - - - - Files - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - QAbstractItemView::NoSelection - - - true - - - 0 - - - 300 - - - false - - - true - - - - PATH - - - - - SIZE - - - - - - - - - Trackers - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - 0 - - - - NAME - - - - - - - - - - - - - - LazyLoadList - QListWidget -
TriblerGUI.widgets.lazyloadlist.h
-
- - LazyTableView - QTableView -
TriblerGUI.widgets.lazytableview.h
-
- - TorrentDetailsTabWidget - QTabWidget -
TriblerGUI.widgets.torrentdetailstabwidget.h
- 1 -
- - EllipseButton - QToolButton -
TriblerGUI.widgets.ellipsebutton.h
-
-
- - -
diff --git a/TriblerGUI/qt_resources/torrent_details_container.ui b/TriblerGUI/qt_resources/torrent_details_container.ui new file mode 100644 index 00000000000..8d67de876da --- /dev/null +++ b/TriblerGUI/qt_resources/torrent_details_container.ui @@ -0,0 +1,432 @@ + + + details_container + + + + 0 + 0 + 585 + 241 + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + ArrowCursor + + + Form + + + false + + + background-color: #202020; + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + QLabel { +color: white; +} +QTabWidget { +border: none; +background-color: #202020; +} +QTabBar::tab { + color: white; + background-color: #555; +} +QTabBar::tab:selected { + color: #555; + background-color: #777; +} + + + 0 + + + + background-color: #202020; + + + Details + + + + 0 + + + 12 + + + 12 + + + 12 + + + 12 + + + + + border: none; + + + true + + + + + 0 + 0 + 561 + 196 + + + + + Qt::AlignCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + font-weight: bold; + + + Name + + + + + + + + + + true + + + + + + + font-weight: bold; + + + Category + + + + + + + font-weight: bold; + + + Size + + + + + + + + + + + + + + + + + + + + + font-weight: bold; margin-top:5px + + + Health + + + + + + + Qt::AlignCenter + + + Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop + + + + + + + + + + + + PointingHandCursor + + + + EllipseButton{ + border: 1px solid #b5b5b5; + border-radius: 4px; + color: white + } + EllipseButton::hover{ + color: #333; + background-color:#c5c5c5; + } + + + + Re-check + + + + + + + + + + + + + + Files + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QTreeWidget { +border: none; +font-size: 13px; +} +QTreeWidget::item { +color: white; +border-bottom: 1px solid #303030; +} +QTreeWidget::item:hover { +background-color: #303030; +} +QTreeWidget::item::selected { +background-color: #444; +} +QHeaderView { +background-color: transparent; +} +QHeaderView::section { +background-color: transparent; +border: none; +color: #B5B5B5; +padding: 10px; +font-size: 14px; +border-bottom: 1px solid #303030; +} +QHeaderView::drop-down { +color: red; +} +QHeaderView::section:hover { +color: white; +} +QTableCornerButton::section { +background-color: transparent; +} + + + QAbstractItemView::NoSelection + + + 0 + + + 300 + + + false + + + true + + + + PATH + + + + + SIZE + + + + + + + + + Trackers + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QTreeWidget { +border: none; +font-size: 13px; +} +QTreeWidget::item { +color: white; +border-bottom: 1px solid #303030; +} +QTreeWidget::item:hover { +background-color: #303030; +} +QTreeWidget::item::selected { +background-color: #444; +} +QHeaderView { +background-color: transparent; +} +QHeaderView::section { +background-color: transparent; +border: none; +color: #B5B5B5; +padding: 10px; +font-size: 14px; +border-bottom: 1px solid #303030; +} +QHeaderView::drop-down { +color: red; +} +QHeaderView::section:hover { +color: white; +} +QTableCornerButton::section { +background-color: transparent; +} + + + 0 + + + + NAME + + + + + + + + + + + + + EllipseButton + QToolButton +
TriblerGUI.widgets.ellipsebutton.h
+
+ + TorrentDetailsTabWidget + QTabWidget +
TriblerGUI.widgets.torrentdetailstabwidget.h
+ 1 +
+
+ + +
diff --git a/TriblerGUI/qt_resources/torrents_list.ui b/TriblerGUI/qt_resources/torrents_list.ui new file mode 100644 index 00000000000..cd24dba076a --- /dev/null +++ b/TriblerGUI/qt_resources/torrents_list.ui @@ -0,0 +1,181 @@ + + + torrents_list + + + + 0 + 0 + 830 + 569 + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 16777215 + 16777215 + + + + ArrowCursor + + + Form + + + false + + + background-color: #202020; + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QSplitter::handle { background-color: #555; } + + + Qt::Vertical + + + false + + + false + + + + QTableView { +border: none; +font-size: 13px; +outline: 0; +} + +QTableView::item { +color: white; +height: 40px; +border-bottom: 1px solid #303030; +} + + +QTableView::item::hover { + background-color: rgba(255,255,255, 50); + } + + QHeaderView { + background-color: transparent; + } + QHeaderView::section { + background-color: transparent; + border: none; + color: #B5B5B5; + padding: 10px; + font-size: 14px; + border-bottom: 1px solid #303030; + } + QHeaderView::section:hover { + color: white; + } + QTableCornerButton::section { + background-color: transparent; + } + QHeaderView::section:up-arrow { + color: white; + } + QHeaderView::section:down-arrow { + color: white; + } + + + + Qt::ScrollBarAlwaysOff + + + QAbstractItemView::ExtendedSelection + + + QAbstractItemView::SelectRows + + + false + + + true + + + false + + + false + + + false + + + + + + 0 + 0 + + + + + 0 + 200 + + + + + 16777215 + 200 + + + + + + + + + + TorrentsTableView + QTableView +
TriblerGUI.widgets.lazytableview.h
+
+ + TorrentDetailsContainer + QWidget +
TriblerGUI.widgets.torrentdetailscontainer.h
+ 1 +
+
+ + +
diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index f2bdaf4ec56..a05b5aea632 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -12,8 +12,8 @@ from urllib import pathname2url, unquote import six -from PyQt5.QtCore import QCoreApplication, QObject, QPoint, QSettings, QStringListModel, QTimer, QUrl, Qt, \ - pyqtSignal, pyqtSlot +from PyQt5.QtCore import QCoreApplication, QObject, QPoint, QSettings, QStringListModel, QTimer, QUrl, Qt, pyqtSignal, \ + pyqtSlot from PyQt5.QtCore import QDir from PyQt5.QtGui import QDesktopServices, QIcon from PyQt5.QtGui import QKeySequence @@ -25,10 +25,9 @@ from Tribler.Core.Modules.process_checker import ProcessChecker from TriblerGUI.core_manager import CoreManager from TriblerGUI.debug_window import DebugWindow -from TriblerGUI.defs import PAGE_SEARCH_RESULTS, \ - PAGE_HOME, PAGE_EDIT_CHANNEL, PAGE_VIDEO_PLAYER, PAGE_DOWNLOADS, PAGE_SETTINGS, PAGE_SUBSCRIBED_CHANNELS, \ - PAGE_CHANNEL_DETAILS, BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, PAGE_LOADING, \ - PAGE_DISCOVERING, PAGE_DISCOVERED, PAGE_TRUST, SHUTDOWN_WAITING_PERIOD, DEFAULT_API_PORT +from TriblerGUI.defs import BUTTON_TYPE_CONFIRM, BUTTON_TYPE_NORMAL, DEFAULT_API_PORT, PAGE_CHANNEL_DETAILS, \ + PAGE_DISCOVERED, PAGE_DISCOVERING, PAGE_DOWNLOADS, PAGE_EDIT_CHANNEL, PAGE_HOME, PAGE_LOADING, \ + PAGE_SEARCH_RESULTS, PAGE_SETTINGS, PAGE_SUBSCRIBED_CHANNELS, PAGE_TRUST, PAGE_VIDEO_PLAYER, SHUTDOWN_WAITING_PERIOD from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog from TriblerGUI.dialogs.feedbackdialog import FeedbackDialog from TriblerGUI.dialogs.startdownloaddialog import StartDownloadDialog @@ -160,6 +159,7 @@ def on_state_update(new_state): self.loading_page.initialize_loading_page() self.discovering_page.initialize_discovering_page() self.discovered_page.initialize_discovered_page() + self.channel_page.initialize_channel_page() self.trust_page.initialize_trust_page() self.token_mining_page.initialize_token_mining_page() @@ -790,8 +790,8 @@ def clicked_menu_button_debug(self): def clicked_menu_button_subscriptions(self): self.deselect_all_menu_buttons(self.left_menu_button_subscriptions) - self.subscribed_channels_page.load_subscribed_channels() self.stackedWidget.setCurrentIndex(PAGE_SUBSCRIBED_CHANNELS) + self.subscribed_channels_page.load_subscribed_channels() self.navigation_stack = [] self.hide_left_menu_playlist() diff --git a/TriblerGUI/utilities.py b/TriblerGUI/utilities.py index a0c9134b8de..b4c445f82c6 100644 --- a/TriblerGUI/utilities.py +++ b/TriblerGUI/utilities.py @@ -9,6 +9,12 @@ from TriblerGUI.defs import VIDEO_EXTS +def index2uri(index): + infohash = index.model().data_items[index.row()][u'infohash'] + name = index.model().data_items[index.row()][u'name'] + return u"magnet:?xt=urn:btih:%s&dn=%s" % (infohash, name) + + def format_size(num, suffix='B'): for unit in ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z']: if abs(num) < 1024.0: diff --git a/TriblerGUI/widgets/channelpage.py b/TriblerGUI/widgets/channelpage.py index be7f0569ddb..71fa5ee81f9 100644 --- a/TriblerGUI/widgets/channelpage.py +++ b/TriblerGUI/widgets/channelpage.py @@ -1,7 +1,11 @@ +from __future__ import absolute_import + from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget from TriblerGUI.utilities import get_image_path +from TriblerGUI.widgets.tablecontentmodel import TorrentsContentModel +from TriblerGUI.widgets.triblertablecontrollers import TorrentsTableViewController class ChannelPage(QWidget): @@ -12,26 +16,39 @@ class ChannelPage(QWidget): def __init__(self): QWidget.__init__(self) self.channel_info = None + self.model = None + self.controller = None - def initialize_with_channel(self, channel_info): - self.channel_info = channel_info - self.window().channel_page_container.initialize_model(channel_id=channel_info['public_key']) + def initialize_channel_page(self): + self.model = TorrentsContentModel() + self.controller = TorrentsTableViewController(self.model, self.window().channel_page_container, + None, self.window().channel_torrents_filter_input) - container = self.window().channel_page_container - container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) - container.torrents_table.setColumnHidden(container.model.column_position[u'subscribed'], True) - container.buttons_container.setHidden(True) + # Remove the commit control from the delegate for performance + commit_control = self.window().channel_page_container.content_table.delegate.commit_control + self.window().channel_page_container.content_table.delegate.controls.remove(commit_control) - if len(channel_info['public_key']) == 40: - container.top_bar_container.setHidden(True) - else: - container.top_bar_container.setHidden(False) - container.dirty_channel_bar.setHidden(True) + def initialize_with_channel(self, channel_info): + self.channel_info = channel_info - self.window().channel_preview_label.setHidden(int(channel_info['subscribed']) == 1) + self.window().channel_preview_label.setHidden(channel_info['subscribed']) self.window().channel_back_button.setIcon(QIcon(get_image_path('page_back.png'))) # initialize the page about a channel self.window().channel_name_label.setText(channel_info['name']) self.window().num_subs_label.setText(str(channel_info['votes'])) self.window().subscription_widget.initialize_with_channel(channel_info) + self.window().channel_page_container.details_container.hide() + + self.window().channel_page_container.content_table.on_torrent_clicked.connect(self.on_torrent_clicked) + + self.model.channel_pk = channel_info['public_key'] + self.load_torrents() + + def on_torrent_clicked(self, torrent_info): + self.window().channel_page_container.details_container.show() + self.window().channel_page_container.details_tab_widget.update_with_torrent(torrent_info) + + def load_torrents(self): + self.controller.model.reset() + self.controller.load_torrents(1, 50) # Load the first 50 torrents diff --git a/TriblerGUI/widgets/channelview.py b/TriblerGUI/widgets/channelview.py deleted file mode 100644 index 7102d54cbb3..00000000000 --- a/TriblerGUI/widgets/channelview.py +++ /dev/null @@ -1,446 +0,0 @@ -from __future__ import absolute_import - -import base64 -import time -import urllib -from PyQt5 import uic, QtCore - -from PyQt5.QtCore import Qt, QDir, pyqtSignal -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QWidget, QAction, QFileDialog, QAbstractItemView - -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, COMMITTED, NEW -from Tribler.Core.Modules.MetadataStore.serialization import int2time -from Tribler.Core.Modules.restapi.util import HEALTH_MOOT, HEALTH_GOOD, HEALTH_DEAD, HEALTH_CHECKING, HEALTH_ERROR -from TriblerGUI.defs import BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, \ - PAGE_EDIT_CHANNEL_CREATE_TORRENT -from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog -from TriblerGUI.tribler_action_menu import TriblerActionMenu -from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.utilities import get_ui_file_path, format_size -from TriblerGUI.widgets.lazytableview import RemoteTableModel, ACTION_BUTTONS -from TriblerGUI.widgets.torrentdetailstabwidget import TorrentDetailsTabWidget - -commit_status_labels = { - COMMITTED: "Committed", - TODELETE: "To delete", - NEW: "Uncommitted" -} - - -class ChannelContentsModel(RemoteTableModel): - columns = [u'category', u'name', u'size', u'date', u'health', u'subscribed', u'commit_status', ACTION_BUTTONS] - column_headers = [u'Category', u'Title', u'Size', u'Date', u'Health', u'S', u'Status', u''] - column_position = {name: i for i, name in enumerate(columns)} - - column_width = {u'subscribed': 35, - u'date': 80, - u'size': 80, - u'commit_status': 35, - u'name': 200, - u'category': 100, - u'health': 80, - ACTION_BUTTONS: 65} - num_columns = len(columns) - column_flags = { - u'subscribed': Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable, - u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, - u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable, - u'size': Qt.ItemIsEnabled | Qt.ItemIsSelectable, - u'date': Qt.ItemIsEnabled | Qt.ItemIsSelectable, - u'commit_status': Qt.ItemIsEnabled | Qt.ItemIsSelectable, - u'health': Qt.ItemIsEnabled | Qt.ItemIsSelectable, - ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable | Qt.ItemIsEditable - } - - column_display_filters = { - u'size': lambda data: format_size(float(data)), - u'date': lambda data: str((int2time(int(data)).strftime("%Y-%m-%d"))) - } - - def __init__(self, parent=None, channel_id=None, search_query=None, search_type=None, subscribed=None, - commit_widget=None): - self.channel_id = channel_id - self.commit_widget = commit_widget - self.txt_filter = search_query or '' - self.search_type = search_type - self.data_items = [] - self.subscribed = subscribed - - # This dict keeps the mapping of infohashes in data_items to indexes - # It is used by Health Checker to track the health status updates across model refreshes - self.infohashes = {} - self.last_health_check_ts = {} - - super(ChannelContentsModel, self).__init__(parent) - - def headerData(self, num, orientation, role=None): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return self.column_headers[num] - - def _get_remote_data(self, start, end, sort_column=None, sort_order=None): - sort_by = (("-" if sort_order else "") + self.columns[sort_column]) if sort_column else None - request_mgr = TriblerRequestManager() - request_mgr.perform_request( - "search?first=%i&last=%i" % (start, end) - + (('&sort_by=%s' % sort_by) if sort_by else '') - + (('&channel=%s' % self.channel_id) if self.channel_id else '') - + (('&type=%s' % self.search_type) if self.search_type else '') - + (('&txt=%s' % self.txt_filter) if self.txt_filter else '') - + (('&subscribed=%i' % self.subscribed) if self.subscribed else ''), - self._new_items_received_callback) - - def refresh(self): - # Health Checker related - # Infohash to data_items mapping should be cleaned each time we refresh the model - self.infohashes.clear() - super(ChannelContentsModel, self).refresh() - - def _new_items_received_callback(self, response): - # TODO: make commit_widget a separate view into this model instead of using this ugly hook. - # Or just use a QT signal for changing its visibility state. - if not response: - return - if self.commit_widget: - self.commit_widget.setHidden(not ("chant_dirty" in response and response["chant_dirty"])) - if 'torrents' in response: - # Health checker related - # Update infohashes -> data_items mapping - for n, item in enumerate(response['torrents']): - self.infohashes[item[u'infohash']] = len(self.data_items) + n - self._on_new_items_received(response['torrents']) - - def data(self, index, role): - if role == Qt.TextAlignmentRole: - if index.column() == self.column_position[u'date']: - return Qt.AlignHCenter | Qt.AlignVCenter - if index.column() == self.column_position[u'size']: - return Qt.AlignHCenter | Qt.AlignVCenter - - i = index.row() - j = index.column() - if role == Qt.DisplayRole: - column = self.columns[j] - data = self.data_items[i][column] if column in self.data_items[i] else u'UNDEFINED' - return self.column_display_filters.get(column, str(data))(data) \ - if column in self.column_display_filters else data - - def rowCount(self, parent=QtCore.QModelIndex()): - return len(self.data_items) - - def columnCount(self, parent=QtCore.QModelIndex()): - return self.num_columns - - def _set_remote_data(self): - pass - - def flags(self, index): - return self.column_flags[self.columns[index.column()]] - - def add_torrent_to_channel(self, filename): - with open(filename, "rb") as torrent_file: - torrent_content = urllib.quote_plus(base64.b64encode(torrent_file.read())) - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents" % - self.channel_id, - self.on_torrent_to_channel_added, method='PUT', - data='torrent=%s' % torrent_content) - - def add_dir_to_channel(self, dirname, recursive=False): - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents" % - self.channel_id, - self.on_torrent_to_channel_added, method='PUT', - data=((u'torrents_dir=%s' % dirname) + - (u'&recursive=1' if recursive else u'')).encode('utf-8')) - - def add_torrent_url_to_channel(self, url): - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (self.channel_id, url), - self.on_torrent_to_channel_added, method='PUT') - - def on_torrent_to_channel_added(self, result): - if not result: - return - if 'added' in result: - self.refresh() - - def update_torrent_health(self, infohash, seeders, leechers, health): - if infohash in self.infohashes: - row = self.infohashes[infohash] - self.data_items[row][u'num_seeders'] = seeders - self.data_items[row][u'num_leechers'] = leechers - self.data_items[row][u'health'] = health - index = self.index(row, self.column_position[u'health']) - self.dataChanged.emit(index, index, []) - - def check_torrent_health(self, index): - timeout = 15 - infohash = self.data_items[index.row()][u'infohash'] - - # TODO: move timeout check to the endpoint - if infohash in self.last_health_check_ts and \ - (time.time() - self.last_health_check_ts[infohash] < timeout): - return - self.last_health_check_ts[infohash] = time.time() - - def on_cancel_health_check(): - pass - - def on_health_response(response): - - self.last_health_check_ts[infohash] = time.time() - total_seeders = 0 - total_leechers = 0 - - if not response or 'error' in response: - self.update_torrent_health(infohash, 0, 0, HEALTH_ERROR) # Just set the health to 0 seeders, 0 leechers - return - - for _, status in response['health'].iteritems(): - if 'error' in status: - continue # Timeout or invalid status - total_seeders += int(status['seeders']) - total_leechers += int(status['leechers']) - - if total_seeders > 0: - health = HEALTH_GOOD - elif total_leechers > 0: - health = HEALTH_MOOT - else: - health = HEALTH_DEAD - - self.update_torrent_health(infohash, total_seeders, total_leechers, health) - - self.data_items[index.row()][u'health'] = HEALTH_CHECKING - index_upd = self.index(index.row(), self.column_position[u'health']) - self.dataChanged.emit(index_upd, index_upd, []) - health_request_mgr = TriblerRequestManager() - health_request_mgr.perform_request("torrents/%s/health?timeout=%s&refresh=%d" % - (infohash, timeout, 1), - on_health_response, capture_errors=False, priority="LOW", - on_cancel=on_cancel_health_check) - - -class ChannelViewWidget(QWidget): - channel_entry_clicked = pyqtSignal(dict) - - def __init__(self, parent=None): - self.remove_torrent_requests = [] - self.model = None - self.dialog = None - self.chosen_dir = None - self.details_tab_widget = None - QWidget.__init__(self, parent=parent) - uic.loadUi(get_ui_file_path('channel_view.ui'), self) - - # Connect torrent addition/removal buttons - self.remove_selected_button.clicked.connect(self.on_torrents_remove_selected_clicked) - self.remove_all_button.clicked.connect(self.on_torrents_remove_all_clicked) - self.add_button.clicked.connect(self.on_torrents_add_clicked) - - # "Commit changes" widget is hidden by default and only shown when necessary - self.dirty_channel_bar.setHidden(True) - self.edit_channel_commit_button.clicked.connect(self.clicked_edit_channel_commit_button) - - # Connect "filter" edit box - self.search_edit.editingFinished.connect(self.search_edit_finished) - - self.details_tab_widget = self.findChild(TorrentDetailsTabWidget, "details_tab_widget") - self.details_tab_widget.initialize_details_widget() - self.details_tab_widget.health_check_clicked.connect(self.on_details_tab_widget_health_check_clicked) - - self.torrents_table.clicked.connect(self.on_table_item_clicked) - self.torrents_table.verticalHeader().hide() - self.torrents_table.setSelectionBehavior(QAbstractItemView.SelectRows) - self.torrents_table.setSelectionMode(QAbstractItemView.ExtendedSelection) - - def on_details_tab_widget_health_check_clicked(self, torrent_info): - infohash = torrent_info[u'infohash'] - if infohash in self.model.infohashes: - self.model.check_torrent_health(self.model.index(self.model.infohashes[infohash], 0)) - - def on_table_item_clicked(self, item): - if item.column() == self.model.column_position[ACTION_BUTTONS] or \ - item.column() == self.model.column_position[u'subscribed'] or \ - item.column() == self.model.column_position[u'commit_status']: - return - table_entry = self.model.data_items[item.row()] - if table_entry['type'] == u'torrent': - self.details_tab_widget.update_with_torrent(table_entry) - self.details_tab_widget.setHidden(False) - self.model.check_torrent_health(item) - elif table_entry['type'] == u'channel': - self.channel_entry_clicked.emit(table_entry) - - def set_model(self, model): - self.model = model - self.torrents_table.setModel(self.model) - self.reset_column_width() - self.details_tab_widget.setHidden(True) - - # TODO: instead, refactor Details Widget into a View - # This ensures that when the Health Checker updates the state of some rows in the model, - # the Details Widget will be notified about these changes - self.model.dataChanged.connect(self.details_tab_widget.update_from_model) - - def reset_column_width(self): - for col in self.model.column_width: - self.torrents_table.setColumnWidth(self.model.column_position[col], self.model.column_width[col]) - - def initialize_model(self, channel_id=None, search_query=None, search_type=None, subscribed=None): - self.model = ChannelContentsModel(parent=None, - channel_id=channel_id, - search_query=search_query, - search_type=search_type, - subscribed=subscribed, - commit_widget=self.dirty_channel_bar) - self.set_model(self.model) - - # Search related-methods - def search_edit_finished(self): - if self.model.txt_filter != self.search_edit.text(): - self.model.txt_filter = self.search_edit.text() - self.model.refresh() - - # Torrent removal-related methods - def on_torrents_remove_selected_clicked(self): - selected_items = self.torrents_table.selectedIndexes() - num_selected = len(selected_items) - if num_selected == 0: - return - - selected_infohashes = [self.model.data_items[row][u'infohash'] for row in - set([index.row() for index in selected_items])] - self.dialog = ConfirmationDialog(self, "Remove %s selected torrents" % num_selected, - "Are you sure that you want to remove %s selected torrents " - "from your channel?" % len(selected_infohashes), - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(lambda action: - self.on_torrents_remove_selected_action(action, selected_infohashes)) - self.dialog.show() - - def on_torrent_removed(self, json_result): - if not json_result: - return - if 'removed' in json_result and json_result['removed']: - self.model.refresh() - - def on_torrents_remove_selected_action(self, action, items): - if action == 0: - if isinstance(items, list): - infohash = ",".join(items) - else: - infohash = items - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (self.model.channel_id, infohash), - self.on_torrent_removed, method='DELETE') - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - - def on_torrents_remove_all_action(self, action): - if action == 0: - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents/*" % self.model.channel_id, - None, method='DELETE') - self.model.refresh() - - self.dialog.close_dialog() - self.dialog = None - - def on_torrents_remove_all_clicked(self): - self.dialog = ConfirmationDialog(self.window(), "Remove all torrents", - "Are you sure that you want to remove all torrents from your channel? " - "You cannot undo this action.", - [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) - self.dialog.button_clicked.connect(self.on_torrents_remove_all_action) - self.dialog.show() - - # Torrent addition-related methods - def on_add_torrents_browse_dir(self): - chosen_dir = QFileDialog.getExistingDirectory(self, - "Please select the directory containing the .torrent files", - QDir.homePath(), - QFileDialog.ShowDirsOnly) - if not chosen_dir: - return - - self.chosen_dir = chosen_dir - self.dialog = ConfirmationDialog(self, "Add torrents from directory", - "Add all torrent files from the following directory to your Tribler channel:\n\n%s" % - chosen_dir, - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - checkbox_text="Include subdirectories (recursive mode)") - self.dialog.button_clicked.connect(self.on_confirm_add_directory_dialog) - self.dialog.show() - - def on_confirm_add_directory_dialog(self, action): - if action == 0: - self.model.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) - - if self.dialog: - self.dialog.close_dialog() - self.dialog = None - self.chosen_dir = None - - def on_torrents_add_clicked(self): - menu = TriblerActionMenu(self) - - browse_files_action = QAction('Import torrent from file', self) - browse_dir_action = QAction('Import torrent(s) from dir', self) - add_url_action = QAction('Add URL', self) - create_torrent_action = QAction('Create torrent from file(s)', self) - - browse_files_action.triggered.connect(self.on_add_torrent_browse_file) - browse_dir_action.triggered.connect(self.on_add_torrents_browse_dir) - add_url_action.triggered.connect(self.on_add_torrent_from_url) - create_torrent_action.triggered.connect(self.on_create_torrent_from_files) - - menu.addAction(browse_files_action) - menu.addAction(browse_dir_action) - menu.addAction(add_url_action) - menu.addAction(create_torrent_action) - - menu.exec_(QCursor.pos()) - - def on_create_torrent_from_files(self): - self.window().edit_channel_details_create_torrent.initialize(self.model.channel_id) - self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_CREATE_TORRENT) - - def on_add_torrent_browse_file(self): - filename = QFileDialog.getOpenFileName(self, "Please select the .torrent file", "", "Torrent files (*.torrent)") - if not filename[0]: - return - self.model.add_torrent_to_channel(filename[0]) - - def on_add_torrent_from_url(self): - self.dialog = ConfirmationDialog(self, "Add torrent from URL/magnet link", - "Please enter the URL/magnet link in the field below:", - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - show_input=True) - self.dialog.dialog_widget.dialog_input.setPlaceholderText('URL/magnet link') - self.dialog.button_clicked.connect(self.on_torrent_from_url_dialog_done) - self.dialog.show() - - def on_torrent_from_url_dialog_done(self, action): - if action == 0: - url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) - self.model.add_torrent_url_to_channel(url) - self.dialog.close_dialog() - self.dialog = None - - # Commit button-related methods - def clicked_edit_channel_commit_button(self): - request_mgr = TriblerRequestManager() - request_mgr.perform_request("mychannel", self.on_channel_committed, - data=u'commit_changes=1'.encode('utf-8'), - method='POST') - - def on_channel_committed(self, result): - if not result: - return - if 'modified' in result: - self.model.refresh() diff --git a/TriblerGUI/widgets/createtorrentpage.py b/TriblerGUI/widgets/createtorrentpage.py index c4e5c44e14e..eeeb2e0e8ae 100644 --- a/TriblerGUI/widgets/createtorrentpage.py +++ b/TriblerGUI/widgets/createtorrentpage.py @@ -1,14 +1,17 @@ +from __future__ import absolute_import + import os import urllib from PyQt5.QtCore import QDir from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QAction, QFileDialog, QWidget -from PyQt5.QtWidgets import QWidget, QFileDialog, QAction +from six.moves import xrange -from TriblerGUI.tribler_action_menu import TriblerActionMenu -from TriblerGUI.defs import PAGE_EDIT_CHANNEL_TORRENTS, BUTTON_TYPE_NORMAL +from TriblerGUI.defs import BUTTON_TYPE_NORMAL, PAGE_EDIT_CHANNEL_TORRENTS from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog +from TriblerGUI.tribler_action_menu import TriblerActionMenu from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.utilities import get_image_path @@ -27,8 +30,7 @@ def __init__(self): self.selected_item_index = -1 self.initialized = False - def initialize(self, identifier): - self.channel_identifier = identifier + def initialize(self): self.window().create_torrent_name_field.setText('') self.window().create_torrent_description_field.setText('') self.window().create_torrent_files_list.clear() @@ -109,8 +111,7 @@ def on_torrent_created(self, result): def add_torrent_to_channel(self, torrent): post_data = str("torrent=%s" % urllib.quote_plus(torrent)) self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("channels/discovered/%s/torrents" % - self.channel_identifier, self.on_torrent_to_channel_added, + self.request_mgr.perform_request("mychannel/torrents", self.on_torrent_to_channel_added, data=post_data, method='PUT') def on_torrent_to_channel_added(self, result): diff --git a/TriblerGUI/widgets/discoveredpage.py b/TriblerGUI/widgets/discoveredpage.py index 08adde1b9e8..6ae7ba19fae 100644 --- a/TriblerGUI/widgets/discoveredpage.py +++ b/TriblerGUI/widgets/discoveredpage.py @@ -2,7 +2,8 @@ from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS +from TriblerGUI.widgets.tablecontentmodel import ChannelsContentModel +from TriblerGUI.widgets.triblertablecontrollers import ChannelsTableViewController class DiscoveredPage(QWidget): @@ -13,21 +14,17 @@ class DiscoveredPage(QWidget): def __init__(self): QWidget.__init__(self) self.initialized = False + self.model = None + self.controller = None def initialize_discovered_page(self): if not self.initialized: self.initialized = True - container = self.window().discovered_channels_container - - container.initialize_model(search_type=u'channel') - container.channel_entry_clicked.connect(self.window().on_channel_clicked) - container.details_tab_widget.setHidden(True) - container.buttons_container.setHidden(True) - container.top_bar_container.setHidden(True) - - container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) - container.torrents_table.setColumnHidden(container.model.column_position[u'health'], True) - container.torrents_table.setColumnHidden(container.model.column_position[ACTION_BUTTONS], True) + self.model = ChannelsContentModel() + self.controller = ChannelsTableViewController(self.model, self.window().discovered_channels_list, + self.window().num_discovered_channels_label, + self.window().discovered_channels_filter_input) def load_discovered_channels(self): - self.window().discovered_channels_container.model.refresh() + self.controller.model.reset() + self.controller.load_channels(1, 50) # Load the first 50 discovered channels diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index e9005fd6cf4..31df3ff8374 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -1,26 +1,39 @@ from __future__ import absolute_import import os +import urllib +from base64 import b64encode -from PyQt5.QtWidgets import QWidget, QFileDialog +from PyQt5.QtCore import QDir, pyqtSignal +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QAction, QFileDialog, QWidget -from TriblerGUI.defs import PAGE_EDIT_CHANNEL_OVERVIEW, BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM, \ - PAGE_EDIT_CHANNEL_SETTINGS, PAGE_EDIT_CHANNEL_TORRENTS +from TriblerGUI.defs import BUTTON_TYPE_CONFIRM, BUTTON_TYPE_NORMAL, COMMIT_STATUS_TODELETE, \ + PAGE_EDIT_CHANNEL_CREATE_TORRENT, PAGE_EDIT_CHANNEL_OVERVIEW, PAGE_EDIT_CHANNEL_SETTINGS, PAGE_EDIT_CHANNEL_TORRENTS from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog +from TriblerGUI.tribler_action_menu import TriblerActionMenu from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.widgets.tablecontentmodel import MyTorrentsContentModel +from TriblerGUI.widgets.triblertablecontrollers import MyTorrentsTableViewController class EditChannelPage(QWidget): """ This class is responsible for managing lists and data on your channel page """ + on_torrents_removed = pyqtSignal(list) + on_all_torrents_removed = pyqtSignal() + on_commit = pyqtSignal() def __init__(self): QWidget.__init__(self) self.channel_overview = None + self.chosen_dir = None self.dialog = None self.editchannel_request_mgr = None + self.model = None + self.controller = None def initialize_edit_channel_page(self): self.window().create_channel_intro_button.clicked.connect(self.on_create_channel_intro_button_clicked) @@ -32,6 +45,7 @@ def initialize_edit_channel_page(self): self.window().create_channel_button.clicked.connect(self.on_create_channel_button_pressed) self.window().edit_channel_save_button.clicked.connect(self.on_edit_channel_save_button_pressed) + self.window().edit_channel_commit_button.clicked.connect(self.clicked_edit_channel_commit_button) # Tab bar buttons self.window().channel_settings_tab.initialize() @@ -39,6 +53,19 @@ def initialize_edit_channel_page(self): self.window().export_channel_button.clicked.connect(self.on_export_mdblob) + # Connect torrent addition/removal buttons + self.window().remove_selected_button.clicked.connect(self.on_torrents_remove_selected_clicked) + self.window().remove_all_button.clicked.connect(self.on_torrents_remove_all_clicked) + self.window().add_button.clicked.connect(self.on_torrents_add_clicked) + + self.model = MyTorrentsContentModel() + self.controller = MyTorrentsTableViewController(self.model, self.window().edit_channel_torrents_container, + self.window().edit_channel_torrents_num_items_label, + self.window().edit_channel_torrents_filter) + self.window().edit_channel_torrents_container.details_tab_widget.hide() + self.window().dirty_channel_status_bar.hide() + self.window().edit_channel_commit_button.setEnabled(False) + def load_my_channel_overview(self): if not self.channel_overview: self.window().edit_channel_stacked_widget.setCurrentIndex(2) @@ -55,21 +82,24 @@ def initialize_with_channel_overview(self, overview): return self.channel_overview = overview["mychannel"] + if self.channel_overview['dirty']: + self.window().dirty_channel_status_bar.show() + self.window().edit_channel_commit_button.setEnabled(True) + self.window().export_channel_button.setHidden(False) self.window().edit_channel_name_label.setText("My channel") self.window().edit_channel_overview_name_label.setText(self.channel_overview["name"]) self.window().edit_channel_description_label.setText(self.channel_overview["description"]) - self.window().edit_channel_identifier_label.setText(self.channel_overview["identifier"]) + self.window().edit_channel_identifier_label.setText(self.channel_overview["public_key"]) self.window().edit_channel_name_edit.setText(self.channel_overview["name"]) self.window().edit_channel_description_edit.setText(self.channel_overview["description"]) self.window().edit_channel_stacked_widget.setCurrentIndex(1) - self.window().edit_channel_torrents_list.initialize_model(self.channel_overview["identifier"]) - self.window().edit_channel_torrents_list.torrents_table.setColumnHidden( - self.window().edit_channel_torrents_list.model.column_position[u'subscribed'], True) + # Initiate the right model + self.model.channel_pk = self.channel_overview["public_key"] def on_create_channel_button_pressed(self): channel_name = self.window().new_channel_name_edit.text() @@ -116,8 +146,13 @@ def clicked_tab_button(self, tab_button_name): elif tab_button_name == "edit_channel_settings_button": self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_SETTINGS) elif tab_button_name == "edit_channel_torrents_button": + self.load_my_torrents() self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_TORRENTS) + def load_my_torrents(self): + self.controller.model.reset() + self.controller.load_torrents(1, 50) # Load the first 50 torrents + def on_create_channel_intro_button_clicked(self): self.window().create_channel_form.show() self.window().create_channel_intro_button_container.hide() @@ -131,7 +166,7 @@ def on_export_mdblob(self): return # Show confirmation dialog where we specify the name of the file - mdblob_name = self.channel_overview["identifier"] + mdblob_name = self.channel_overview["public_key"] dialog = ConfirmationDialog(self, "Export mdblob file", "Please enter the name of the channel metadata file:", [('SAVE', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], @@ -163,3 +198,182 @@ def on_export_download_request_done(dest_path, data): dialog.dialog_widget.dialog_input.setFocus() dialog.button_clicked.connect(on_export_download_dialog_done) dialog.show() + + # Torrent removal-related methods + def on_torrents_remove_selected_clicked(self): + selected_items = self.controller.table_view.selectedIndexes() + num_selected = len(selected_items) + if num_selected == 0: + return + + selected_infohashes = [self.model.data_items[row][u'infohash'] for row in + set([index.row() for index in selected_items])] + self.dialog = ConfirmationDialog(self, "Remove %s selected torrents" % len(selected_infohashes), + "Are you sure that you want to remove %s selected torrents " + "from your channel?" % len(selected_infohashes), + [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) + self.dialog.button_clicked.connect(lambda action: + self.on_torrents_remove_selected_action(action, selected_infohashes)) + self.dialog.show() + + def on_torrents_remove_selected_action(self, action, items): + if action == 0: + items = [str(item) for item in items] + infohashes = ",".join(items) + + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents", + lambda response: self.on_torrents_removed_response(response, items), + data='infohashes=%s&status=%s' % (infohashes, COMMIT_STATUS_TODELETE), + method='POST') + if self.dialog: + self.dialog.close_dialog() + self.dialog = None + + def on_torrents_removed_response(self, json_result, infohashes): + if not json_result: + return + + if 'success' in json_result and json_result['success']: + self.on_torrents_removed.emit(infohashes) + self.load_my_torrents() + + def on_torrents_remove_all_clicked(self): + self.dialog = ConfirmationDialog(self.window(), "Remove all torrents", + "Are you sure that you want to remove all torrents from your channel?", + [('CONFIRM', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)]) + self.dialog.button_clicked.connect(self.on_torrents_remove_all_action) + self.dialog.show() + + def on_torrents_remove_all_action(self, action): + if action == 0: + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents", self.on_all_torrents_removed_response, method='DELETE') + + self.dialog.close_dialog() + self.dialog = None + + def on_all_torrents_removed_response(self, json_result): + if not json_result: + return + + if 'success' in json_result and json_result['success']: + self.on_all_torrents_removed.emit() + self.load_my_torrents() + + # Torrent addition-related methods + def on_add_torrents_browse_dir(self): + chosen_dir = QFileDialog.getExistingDirectory(self, + "Please select the directory containing the .torrent files", + QDir.homePath(), + QFileDialog.ShowDirsOnly) + if not chosen_dir: + return + + self.chosen_dir = chosen_dir + self.dialog = ConfirmationDialog(self, "Add torrents from directory", + "Add all torrent files from the following directory " + "to your Tribler channel:\n\n%s" % + chosen_dir, + [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], + checkbox_text="Include subdirectories (recursive mode)") + self.dialog.button_clicked.connect(self.on_confirm_add_directory_dialog) + self.dialog.show() + + def on_confirm_add_directory_dialog(self, action): + if action == 0: + self.model.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) + + if self.dialog: + self.dialog.close_dialog() + self.dialog = None + self.chosen_dir = None + + def on_torrents_add_clicked(self): + menu = TriblerActionMenu(self) + + browse_files_action = QAction('Import torrent from file', self) + browse_dir_action = QAction('Import torrent(s) from dir', self) + add_url_action = QAction('Add URL', self) + create_torrent_action = QAction('Create torrent from file(s)', self) + + browse_files_action.triggered.connect(self.on_add_torrent_browse_file) + browse_dir_action.triggered.connect(self.on_add_torrents_browse_dir) + add_url_action.triggered.connect(self.on_add_torrent_from_url) + create_torrent_action.triggered.connect(self.on_create_torrent_from_files) + + menu.addAction(browse_files_action) + menu.addAction(browse_dir_action) + menu.addAction(add_url_action) + menu.addAction(create_torrent_action) + + menu.exec_(QCursor.pos()) + + def on_create_torrent_from_files(self): + self.window().edit_channel_details_create_torrent.initialize() + self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_CREATE_TORRENT) + + def on_add_torrent_browse_file(self): + filename = QFileDialog.getOpenFileName(self, "Please select the .torrent file", "", "Torrent files (*.torrent)") + if not filename[0]: + return + self.model.add_torrent_to_channel(filename[0]) + + def on_add_torrent_from_url(self): + self.dialog = ConfirmationDialog(self, "Add torrent from URL/magnet link", + "Please enter the URL/magnet link in the field below:", + [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], + show_input=True) + self.dialog.dialog_widget.dialog_input.setPlaceholderText('URL/magnet link') + self.dialog.button_clicked.connect(self.on_torrent_from_url_dialog_done) + self.dialog.show() + + def on_torrent_from_url_dialog_done(self, action): + if action == 0: + url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) + self.model.add_torrent_url_to_channel(url) + self.dialog.close_dialog() + self.dialog = None + + # Commit button-related methods + def clicked_edit_channel_commit_button(self): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/commit", self.on_channel_committed, + method='POST') + + def on_channel_committed(self, result): + if not result: + return + if 'success' in result and result['success']: + self.window().dirty_channel_status_bar.hide() + self.window().edit_channel_commit_button.setEnabled(False) + self.on_commit.emit() + self.load_my_torrents() + + def add_torrent_to_channel(self, filename): + with open(filename, "rb") as torrent_file: + torrent_content = urllib.quote_plus(b64encode(torrent_file.read())) + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents", + self.on_torrent_to_channel_added, method='PUT', + data='torrent=%s' % torrent_content) + + def add_dir_to_channel(self, dirname, recursive=False): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents" % + self.channel_id, + self.on_torrent_to_channel_added, method='PUT', + data=((u'torrents_dir=%s' % dirname) + + (u'&recursive=1' if recursive else u'')).encode('utf-8')) + + def add_torrent_url_to_channel(self, url): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents/%s" % url, + self.on_torrent_to_channel_added, method='PUT') + + def on_torrent_to_channel_added(self, result): + if not result: + return + + if 'added' in result: + self.load_my_torrents() diff --git a/TriblerGUI/widgets/home_recommended_item.py b/TriblerGUI/widgets/home_recommended_item.py index 7e3f60802e9..29953b3aee7 100644 --- a/TriblerGUI/widgets/home_recommended_item.py +++ b/TriblerGUI/widgets/home_recommended_item.py @@ -1,12 +1,11 @@ -from urllib import quote_plus +from __future__ import absolute_import from PyQt5.QtCore import QPoint, QSize, Qt from PyQt5.QtGui import QIcon -from PyQt5.QtWidgets import QWidget, QLabel, QSizePolicy, QToolButton +from PyQt5.QtWidgets import QLabel, QSizePolicy, QToolButton, QWidget -from TriblerGUI.dialogs.startdownloaddialog import StartDownloadDialog from TriblerGUI.tribler_window import fc_home_recommended_item -from TriblerGUI.utilities import pretty_date, get_image_path, format_size, get_gui_setting +from TriblerGUI.utilities import format_size, get_image_path HOME_ITEM_FONT_SIZE = 44 @@ -97,7 +96,7 @@ def update_with_channel(self, channel): self.thumbnail_widget.initialize(channel["name"], HOME_ITEM_FONT_SIZE) self.main_label.setText(channel["name"]) - self.detail_label.setText("Updated " + pretty_date(channel["modified"])) + self.detail_label.setText("%d torrents" % channel["torrents"]) self.category_label.setHidden(True) self.setCursor(Qt.PointingHandCursor) diff --git a/TriblerGUI/widgets/homepage.py b/TriblerGUI/widgets/homepage.py index b97eb35c06b..312dc732135 100644 --- a/TriblerGUI/widgets/homepage.py +++ b/TriblerGUI/widgets/homepage.py @@ -1,10 +1,13 @@ from __future__ import absolute_import + from PyQt5.QtWidgets import QWidget +from six.moves import xrange + from TriblerGUI.defs import PAGE_CHANNEL_DETAILS +from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.widgets.home_recommended_item import HomeRecommendedItem from TriblerGUI.widgets.loading_list_item import LoadingListItem -from TriblerGUI.tribler_request_manager import TriblerRequestManager class HomePage(QWidget): @@ -35,12 +38,14 @@ def load_cells(self): def load_popular_torrents(self): self.recommended_request_mgr = TriblerRequestManager() - self.recommended_request_mgr.perform_request("torrents/random?limit=50", self.received_popular_torrents) + self.recommended_request_mgr.perform_request("metadata/torrents/random?limit=50", + self.received_popular_torrents) def clicked_tab_button(self, tab_button_name): if tab_button_name == "home_tab_channels_button": self.recommended_request_mgr = TriblerRequestManager() - self.recommended_request_mgr.perform_request("channels/popular?limit=50", self.received_popular_channels) + self.recommended_request_mgr.perform_request("metadata/channels/popular?limit=50", + self.received_popular_channels) elif tab_button_name == "home_tab_torrents_button": self.load_popular_torrents() diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index be69e90e962..3be00e0aa97 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -1,107 +1,34 @@ from __future__ import absolute_import, division -from PyQt5 import QtCore -from abc import abstractmethod +from PyQt5.QtCore import QModelIndex, QPoint, pyqtSignal +from PyQt5.QtWidgets import QTableView -from PyQt5.QtCore import QModelIndex, pyqtSignal, QPoint, QRect, Qt, QObject, QSize -from PyQt5.QtGui import QIcon, QPen, QColor, QPainter, QBrush -from PyQt5.QtWidgets import QTableView, QStyledItemDelegate, QStyle - -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, COMMITTED, NEW -from Tribler.Core.Modules.restapi.util import CATEGORY_CHANNEL, CATEGORY_OLD_CHANNEL, HEALTH_MOOT, HEALTH_DEAD, \ - HEALTH_GOOD, HEALTH_UNCHECKED, HEALTH_CHECKING, HEALTH_ERROR +from TriblerGUI.defs import ACTION_BUTTONS, COMMIT_STATUS_COMMITTED, COMMIT_STATUS_NEW, COMMIT_STATUS_TODELETE, \ + PAGE_CHANNEL_DETAILS from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.utilities import get_image_path - -ACTION_BUTTONS = u'action_buttons' - - -class RemoteTableModel(QtCore.QAbstractTableModel): - - def __init__(self, parent=None): - super(RemoteTableModel, self).__init__() - self.data_items = [] - self.item_load_batch = 30 - self.sort_column = 0 - self.sort_order = 0 - - self.load_next_items() - - @abstractmethod - def _get_remote_data(self, start, end): - # This must call self._on_new_items_received as a callback when data received - pass - - @abstractmethod - def _set_remote_data(self): - pass - - def refresh(self): - self.beginResetModel() - self.data_items = [] - self.endResetModel() - self.load_next_items() - - def sort(self, column, order): - self.sort_order = not order - self.sort_column = column - self.refresh() - - def load_next_items(self): - self._get_remote_data(self.rowCount(), self.rowCount() + self.item_load_batch, - sort_column=self.sort_column, - sort_order=self.sort_order) - - def _on_new_items_received(self, new_data_items): - # If we want to block the signal like itemChanged, we must use QSignalBlocker object - old_end = self.rowCount() - new_end = self.rowCount() + len(new_data_items) - if old_end == new_end: - return - self.beginInsertRows(QModelIndex(), old_end, new_end - 1) - self.data_items.extend(new_data_items) - self.endInsertRows() +from TriblerGUI.utilities import index2uri +from TriblerGUI.widgets.tablecontentdelegate import ChannelsButtonsDelegate, SearchResultsDelegate, \ + TorrentsButtonsDelegate +from TriblerGUI.widgets.tablecontentmodel import MyTorrentsContentModel class LazyTableView(QTableView): - def __init__(self, parent=None): - super(LazyTableView, self).__init__(parent) - self.verticalScrollBar().valueChanged.connect(self._on_list_scroll) - self.setSortingEnabled(True) - - def _on_list_scroll(self, event): - if self.verticalScrollBar().value() == self.verticalScrollBar().maximum() and \ - self.model().data_items: # workaround for duplicate calls to _on_list_scroll on view creation - self.model().load_next_items() + """ + This table view is designed to support lazy loading. + When the user reached the end of the table, it will ask the model for more items, and load them dynamically. + """ + pass -class ChannelsTableView(LazyTableView): +class TriblerContentTableView(LazyTableView): # TODO: add redraw when the mouse leaves the view through the header # overloading leaveEvent method could be used for that mouse_moved = pyqtSignal(QPoint, QModelIndex) def __init__(self, parent=None): - super(ChannelsTableView, self).__init__(parent) - self.verticalHeader().setDefaultSectionSize(40) - self.setShowGrid(False) + LazyTableView.__init__(self, parent) - delegate = ChannelsButtonsDelegate() self.setMouseTracking(True) - self.setItemDelegate(delegate) - self.mouse_moved.connect(delegate.on_mouse_moved) - delegate.redraw_required.connect(self.redraw) - delegate.play_button.clicked.connect(self.on_play_button_clicked) - delegate.download_button.clicked.connect(self.on_download_button_clicked) - delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) - delegate.commit_control.clicked.connect(self.on_commit_control_clicked) - - def on_subscribe_control_clicked(self, index): - status = int(index.model().data_items[index.row()][u'subscribed']) - if status: - self.on_unsubscribe_button_clicked(index) - else: - self.on_subscribe_button_clicked(index) - index.model().data_items[index.row()][u'subscribed'] = int(not status) def mouseMoveEvent(self, event): index = QModelIndex(self.indexAt(event.pos())) @@ -110,6 +37,71 @@ def mouseMoveEvent(self, event): def redraw(self): self.viewport().update() + +class SearchResultsTableView(TriblerContentTableView): + """ + This table displays search results, which can be both torrents and channels. + """ + on_torrent_clicked = pyqtSignal(dict) + on_channel_clicked = pyqtSignal(dict) + + def __init__(self, parent=None): + TriblerContentTableView.__init__(self, parent) + + self.delegate = SearchResultsDelegate() + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) + + self.clicked.connect(self.on_table_item_clicked) + + def on_table_item_clicked(self, item): + content_info = self.model().data_items[item.row()] + if content_info['type'] == 'channel': + self.window().channel_page.initialize_with_channel(content_info) + self.window().navigation_stack.append(self.window().stackedWidget.currentIndex()) + self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) + self.on_channel_clicked.emit(content_info) + else: + self.on_torrent_clicked.emit(content_info) + + def resizeEvent(self, _): + self.setColumnWidth(0, 100) + self.setColumnWidth(2, 100) + self.setColumnWidth(3, 100) + self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear + + +class TorrentsTableView(TriblerContentTableView): + """ + This table displays various torrents. + """ + on_torrent_clicked = pyqtSignal(dict) + + def __init__(self, parent=None): + TriblerContentTableView.__init__(self, parent) + + self.delegate = TorrentsButtonsDelegate() + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) + + self.delegate.play_button.clicked.connect(self.on_play_button_clicked) + self.delegate.download_button.clicked.connect(self.on_download_button_clicked) + self.delegate.commit_control.clicked.connect(self.on_commit_control_clicked) + + self.clicked.connect(self.on_table_item_clicked) + + def on_table_item_clicked(self, item): + if (ACTION_BUTTONS in self.model().column_position and + item.column() == self.model().column_position[ACTION_BUTTONS]) or \ + (u'status' in self.model().column_position and + item.column() == self.model().column_position[u'status']): + return + + torrent_info = self.model().data_items[item.row()] + self.on_torrent_clicked.emit(torrent_info) + def on_play_button_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] @@ -130,500 +122,93 @@ def on_play_request_done(_): def on_download_button_clicked(self, index): self.window().start_download_from_uri(index2uri(index)) - def on_subscribe_button_clicked(self, index): - public_key = index.model().data_items[index.row()][u'public_key'] - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/subscribed/%s" % public_key, - self.on_channel_subscribed, method='PUT') - - def on_unsubscribe_button_clicked(self, index): - public_key = index.model().data_items[index.row()][u'public_key'] - request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/subscribed/%s" % public_key, - self.on_channel_unsubscribed, method='DELETE') - def on_commit_control_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] - channel_id = index.model().data_items[index.row()][u'public_key'] - status = index.model().data_items[index.row()][u'commit_status'] + status = index.model().data_items[index.row()][u'status'] + + new_status = COMMIT_STATUS_COMMITTED + if status == COMMIT_STATUS_NEW or status == COMMIT_STATUS_COMMITTED: + new_status = COMMIT_STATUS_TODELETE request_mgr = TriblerRequestManager() - request_mgr.perform_request("channels/discovered/%s/torrents/%s" % - (channel_id, infohash) + \ - ("?restore=1" if status == TODELETE else ''), - self.on_torrent_removed, method='DELETE') + request_mgr.perform_request("mychannel/torrents/%s" % infohash, + lambda response: self.on_torrent_status_updated(response, index), + data='status=%d' % new_status, method='PATCH') - def on_torrent_removed(self, json_result): + def on_torrent_status_updated(self, json_result, index): if not json_result: return - if 'removed' in json_result and json_result['removed']: - self.model().refresh() - - def on_channel_subscribed(self, *args): - pass - - def on_channel_unsubscribed(self, *args): - pass + if 'success' in json_result and json_result['success']: + index.model().data_items[index.row()][u'status'] = json_result['new_status'] -class ChannelsButtonsDelegate(QStyledItemDelegate): - redraw_required = pyqtSignal() - - def __init__(self, parent=None): - super(ChannelsButtonsDelegate, self).__init__(parent) - self.no_index = QModelIndex() - self.hoverrow = None - self.hover_index = None - - # We have to control if mouse is in the buttons box to add some tolerance for vertical mouse - # misplacement around the buttons. The button box effectively overlaps upper and lower rows. - # row 0 - # --------- <- tolerance zone - # row 1 |buttons| - # --------- <- tolerance zone - # row 2 - # button_box_extended_border_ration controls the thickness of the tolerance zone - self.button_box = QRect() - self.button_box_extended_border_ratio = float(0.3) - - # On-demand buttons - self.play_button = PlayIconButton() - self.download_button = DownloadIconButton() - - self.ondemand_container = [self.play_button, self.download_button] - self.subscribe_control = ToggleControl(u'subscribed', - QIcon(get_image_path("subscribed_yes.png")), - QIcon(get_image_path("subscribed_not.png")), - QIcon(get_image_path("subscribed.png"))) - self.commit_control = CommitStatusControl(u'commit_status') - # self.mine_button = MineIconButton() - - self.health_status = HealthStatusDisplay() - - self.controls = [self.play_button, self.download_button, self.subscribe_control, self.commit_control] - - def on_mouse_moved(self, pos, index): - # This method controls for which rows the buttons/box should be drawn - redraw = False - if self.hover_index != index: - self.hover_index = index - self.hoverrow = index.row() - if not self.button_box.contains(pos): - redraw = True - # Redraw when the mouse leaves the table - if index.row() == -1 and self.hoverrow != -1: - self.hoverrow = -1 - redraw = True - - for controls in self.controls: - redraw = controls.on_mouse_moved(pos, index) or redraw - - if redraw: - # TODO: optimize me to only redraw the rows that actually changed! - self.redraw_required.emit() - - def paint(self, painter, option, index): - # Draw 'hover' state highlight for every cell of a row - if index.row() == self.hoverrow: - option.state |= QStyle.State_MouseOver - - # Draw 'health' column - if index.column() == index.model().column_position[u'health']: - # Draw empty cell as the background - super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) - - self.health_status.paint(painter, option.rect, index) - - # Draw 'commit_status' column - elif index.column() == index.model().column_position[u'commit_status']: - # Draw empty cell as the background - super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) - - if index == self.hover_index: - self.commit_control.paint_hover(painter, option.rect, index) - else: - self.commit_control.paint(painter, option.rect, index) - - # Draw 'category' column - elif index.column() == index.model().column_position[u'category']: - # Draw empty cell as the background - super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) - - painter.save() - - lines = QPen(QColor("#B5B5B5"), 1, Qt.SolidLine, Qt.RoundCap) - painter.setPen(lines) - - text = index.model().data_items[index.row()][u'category'] - text_flags = Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine - text_box = painter.boundingRect(option.rect, text_flags, text) - - painter.drawText(text_box, text_flags, text) - bezel_thickness = 4 - bezel_box = QRect(text_box.left() - bezel_thickness, - text_box.top() - bezel_thickness, - text_box.width() + bezel_thickness * 2, - text_box.height() + bezel_thickness * 2) - - painter.setRenderHint(QPainter.Antialiasing) - painter.drawRoundedRect(bezel_box, 20, 80, mode=Qt.RelativeSize) - - painter.restore() - - # Draw 'subscribed' column - elif index.column() == index.model().column_position[u'subscribed']: - # Draw empty cell as the background - super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) - - if index == self.hover_index: - self.subscribe_control.paint_hover(painter, option.rect, index) - else: - self.subscribe_control.paint(painter, option.rect, index) - - # Draw buttons in the ACTION_BUTTONS column - elif index.column() == index.model().column_position[ACTION_BUTTONS]: - # Draw empty cell as the background - super(ChannelsButtonsDelegate, self).paint(painter, option, self.no_index) - - # When the cursor leaves the table, we must "forget" about the button_box - if self.hoverrow == -1: - self.button_box = QRect() - if index.row() == self.hoverrow: - extended_border_height = int(option.rect.height() * self.button_box_extended_border_ratio) - button_box_extended_rect = option.rect.adjusted(0, -extended_border_height, - 0, extended_border_height) - self.button_box = button_box_extended_rect - - active_buttons = [b for b in self.ondemand_container if b.should_draw(index)] - if active_buttons: - for rect, button in split_rect_into_squares(button_box_extended_rect, active_buttons): - button.paint(painter, rect, index) + def resizeEvent(self, _): + if isinstance(self.model(), MyTorrentsContentModel): + self.setColumnWidth(0, 100) + self.setColumnWidth(2, 100) + self.setColumnWidth(3, 100) + self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear else: - # Draw the rest of the columns - super(ChannelsButtonsDelegate, self).paint(painter, option, index) - - def sizeHint(self, option, index): - if index.column() == index.model().column_position[u'subscribed']: - return self.subscribe_control.size_hint(option, index) - - def editorEvent(self, event, model, option, index): - for control in self.controls: - result = control.check_clicked(event, model, option, index) - if result: - return result - return False - - def createEditor(self, parent, option, index): - # Add null editor to action buttons column - if index.column() == index.model().column_position[ACTION_BUTTONS]: - return - if index.column() == index.model().column_position[u'subscribed']: - return - - super(ChannelsButtonsDelegate, self).createEditor(parent, option, index) - - -def index2uri(index): - infohash = index.model().data_items[index.row()][u'infohash'] - name = index.model().data_items[index.row()][u'name'] - return u"magnet:?xt=urn:btih:%s&dn=%s" % (infohash, name) - - -def split_rect_into_squares(rect, buttons): - r = rect - side_size = min(r.width() / len(buttons), r.height() - 2) - y_border = (r.height() - side_size) / 2 - for n, button in enumerate(buttons): - x = r.left() + n * side_size - y = r.top() + y_border - h = side_size - w = side_size - yield QRect(x, y, w, h), button + self.setColumnWidth(0, 100) + self.setColumnWidth(2, 100) + self.setColumnWidth(3, 100) + self.setColumnWidth(4, 100) + self.setColumnWidth(1, self.width() - 404) # Few pixels offset so the horizontal scrollbar does not appear -def index_is_channel(index): - return (index.model().data_items[index.row()][u'category'] == CATEGORY_CHANNEL or - index.model().data_items[index.row()][u'category'] == CATEGORY_OLD_CHANNEL) - - -class IconButton(QObject): - icon = QIcon() - icon_border_ratio = float(0.1) - clicked = pyqtSignal(QModelIndex) - - icon_border = 4 - icon_size = 16 - h = icon_size + 2 * icon_border - w = h - size = QSize(w, h) +class ChannelsTableView(TriblerContentTableView): + """ + This table displays various channels. + """ + on_channel_clicked = pyqtSignal(dict) + on_unsubscribed_channel = pyqtSignal(QModelIndex) + on_subscribed_channel = pyqtSignal(QModelIndex) def __init__(self, parent=None): - super(IconButton, self).__init__(parent=parent) - # rect property contains the active zone for the button - self.rect = QRect() - self.icon_rect = QRect() - self.icon_mode = QIcon.Normal - - def should_draw(self, index): - return True - - def paint(self, painter, rect, index): - # Update button activation rect from the drawing call - self.rect = rect - - x = rect.left() + (rect.width() - self.w) / 2 - y = rect.top() + (rect.height() - self.h) / 2 - icon_rect = QRect(x, y, self.w, self.h) - - self.icon.paint(painter, icon_rect, mode=self.icon_mode) - - def check_clicked(self, event, model, option, index): - if event.type() == QtCore.QEvent.MouseButtonRelease and \ - self.rect.contains(event.pos()): - self.clicked.emit(index) - return True - return False - - def on_mouse_moved(self, pos, index): - old_icon_mode = self.icon_mode - if self.rect.contains(pos): - self.icon_mode = QIcon.Selected - else: - self.icon_mode = QIcon.Normal - return old_icon_mode != self.icon_mode - - def size_hint(self, option, index): - return self.size - + TriblerContentTableView.__init__(self, parent) -class DownloadIconButton(IconButton): - icon = QIcon(get_image_path("downloads.png")) - - def should_draw(self, index): - return not index_is_channel(index) - - -class PlayIconButton(IconButton): - icon = QIcon(get_image_path("play.png")) - - def should_draw(self, index): - return index.model().data_items[index.row()][u'category'] == u'Video' + delegate = ChannelsButtonsDelegate() + self.setItemDelegate(delegate) + self.mouse_moved.connect(delegate.on_mouse_moved) + delegate.redraw_required.connect(self.redraw) + delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) + self.clicked.connect(self.on_table_item_clicked) -class ToggleControl(QObject): - # Column-level controls are stateless collections of methods for visualizing cell data and - # triggering corresponding events. - icon_border = 4 - icon_size = 16 - h = icon_size + 2 * icon_border - w = h - size = QSize(w, h) + def on_subscribe_control_clicked(self, index): + status = int(index.model().data_items[index.row()][u'subscribed']) + if status: + self.on_unsubscribe_button_clicked(index) + else: + self.on_subscribe_button_clicked(index) + index.model().data_items[index.row()][u'subscribed'] = int(not status) - clicked = pyqtSignal(QModelIndex) + def on_subscribe_button_clicked(self, index): + public_key = index.model().data_items[index.row()][u'public_key'] + request_mgr = TriblerRequestManager() + request_mgr.perform_request("metadata/channels/%s" % public_key, + lambda _: self.on_subscribed_channel.emit(index), + data='subscribe=1', method='POST') - def __init__(self, column_name, on_icon, off_icon, hover_icon, parent=None): - super(ToggleControl, self).__init__(parent=parent) - self.on_icon = on_icon - self.off_icon = off_icon - self.hover_icon = hover_icon - self.column_name = column_name - self.last_index = QModelIndex() + def on_unsubscribe_button_clicked(self, index): + public_key = index.model().data_items[index.row()][u'public_key'] + request_mgr = TriblerRequestManager() + request_mgr.perform_request("metadata/channels/%s" % public_key, + lambda _: self.on_unsubscribed_channel.emit(index), + data='subscribe=0', method='POST') - def paint(self, painter, rect, index): - data_item = index.model().data_items[index.row()] - if self.column_name not in data_item or data_item[self.column_name] == '': + def on_table_item_clicked(self, item): + if item.column() == self.model().column_position[u'subscribed']: return - state = 1 == int(data_item[self.column_name]) - icon = self.on_icon if state else self.off_icon - x = rect.left() + (rect.width() - self.w) / 2 - y = rect.top() + (rect.height() - self.h) / 2 - icon_rect = QRect(x, y, self.w, self.h) - icon.paint(painter, icon_rect) - - def paint_hover(self, painter, rect, index): - data_item = index.model().data_items[index.row()] - if self.column_name not in data_item or data_item[self.column_name] == '': - return - icon = self.hover_icon - x = rect.left() + (rect.width() - self.w) / 2 - y = rect.top() + (rect.height() - self.h) / 2 - icon_rect = QRect(x, y, self.w, self.h) - - icon.paint(painter, icon_rect) - - def check_clicked(self, event, model, option, index): - data_item = index.model().data_items[index.row()] - if event.type() == QtCore.QEvent.MouseButtonRelease and \ - index.model().column_position[self.column_name] == index.column() and \ - data_item[self.column_name] != '': - self.clicked.emit(index) - return True - return False - - def size_hint(self, option, index): - return self.size - - def on_mouse_moved(self, pos, index): - if self.last_index != index: - # Handle the case when the cursor leaves the table - if not index.model() or (index.model().column_position[self.column_name] == index.column()): - self.last_index = index - return True - return False - - -class CommitStatusControl(QObject): - # Column-level controls are stateless collections of methods for visualizing cell data and - # triggering corresponding events. - icon_border = 4 - icon_size = 16 - h = icon_size + 2 * icon_border - w = h - size = QSize(w, h) - - clicked = pyqtSignal(QModelIndex) - new_icon = QIcon(get_image_path("plus.svg")) - committed_icon = QIcon(get_image_path("check.svg")) - todelete_icon = QIcon(get_image_path("minus.svg")) - - delete_action_icon = QIcon(get_image_path("delete.png")) - restore_action_icon = QIcon(get_image_path("undo.svg")) - - def __init__(self, column_name, parent=None): - super(CommitStatusControl, self).__init__(parent=parent) - self.column_name = column_name - self.rect = QRect() - self.last_index = QModelIndex() - - def paint(self, painter, rect, index): - data_item = index.model().data_items[index.row()] - if self.column_name not in data_item or data_item[self.column_name] == '': - return - state = data_item[self.column_name] - icon = QIcon() - if state == COMMITTED: - icon = self.committed_icon - elif state == NEW: - icon = self.new_icon - elif state == TODELETE: - icon = self.todelete_icon - - x = rect.left() + (rect.width() - self.w) / 2 - y = rect.top() + (rect.height() - self.h) / 2 - icon_rect = QRect(x, y, self.w, self.h) - - icon.paint(painter, icon_rect) - self.rect = rect - - def paint_hover(self, painter, rect, index): - data_item = index.model().data_items[index.row()] - if self.column_name not in data_item or data_item[self.column_name] == '': - return - state = data_item[self.column_name] - icon = QIcon() - - if state == COMMITTED: - icon = self.delete_action_icon - elif state == NEW: - icon = self.delete_action_icon - elif state == TODELETE: - icon = self.restore_action_icon - - x = rect.left() + (rect.width() - self.w) / 2 - y = rect.top() + (rect.height() - self.h) / 2 - icon_rect = QRect(x, y, self.w, self.h) - - icon.paint(painter, icon_rect) - self.rect = rect - - def check_clicked(self, event, model, option, index): - data_item = index.model().data_items[index.row()] - if event.type() == QtCore.QEvent.MouseButtonRelease and \ - index.model().column_position[self.column_name] == index.column() and \ - data_item[self.column_name] != '': - self.clicked.emit(index) - return True - return False - - def size_hint(self, option, index): - return self.size - - def on_mouse_moved(self, pos, index): - if self.last_index != index: - # Handle the case when the cursor leaves the table - if not index.model(): - self.last_index = index - return True - elif index.model().column_position[self.column_name] == index.column(): - self.last_index = index - return True - return False - - -class HealthStatusDisplay(QObject): - indicator_side = 10 - indicator_border = 2 - health_colors = { - HEALTH_GOOD: QColor(Qt.green), - HEALTH_DEAD: QColor(Qt.red), - HEALTH_MOOT: QColor(Qt.yellow), - HEALTH_UNCHECKED: QColor("#B5B5B5"), - HEALTH_CHECKING: QColor(Qt.blue), - HEALTH_ERROR: QColor(Qt.cyan) - - } - - def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, alignment=Qt.AlignVCenter): - painter.save() - text_flags = Qt.AlignHCenter | alignment | Qt.TextSingleLine - text_box = painter.boundingRect(rect, text_flags, text) - painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap)) - if font: - painter.setFont(font) - - painter.drawText(text_box, text_flags, text) - painter.restore() - - def paint(self, painter, rect, index): - data_item = index.model().data_items[index.row()] - health = data_item[u'health'] - - # ---------------- - # |b---b| | - # |b|i|b| 0S 0L | - # |b---b| | - # ---------------- - - r = rect - - # Indicator ellipse rectangle - y = r.top() + (r.height() - self.indicator_side) / 2 - x = r.left() + self.indicator_border - w = self.indicator_side - h = self.indicator_side - indicator_rect = QRect(x, y, w, h) - - # Paint indicator - painter.save() - painter.setBrush(QBrush(self.health_colors[health])) - painter.setPen(QPen(QColor(Qt.darkGray), 0, Qt.SolidLine, Qt.RoundCap)) - painter.drawEllipse(indicator_rect) - painter.restore() - - x = indicator_rect.left() + indicator_rect.width() + 2 * self.indicator_border - y = r.top() - w = r.width() - indicator_rect.width() - 2 * self.indicator_border - h = r.height() - text_box = QRect(x, y, w, h) - - # Paint status text, if necessary - if health in [HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_ERROR]: - self.draw_text(painter, text_box, health) - else: - seeders = int(data_item[u'num_seeders']) - leechers = int(data_item[u'num_leechers']) + channel_info = self.model().data_items[item.row()] + self.window().channel_page.initialize_with_channel(channel_info) + self.window().navigation_stack.append(self.window().stackedWidget.currentIndex()) + self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) - txt = u'S' + str(seeders) + u' L' + str(leechers) + self.on_channel_clicked.emit(channel_info) - self.draw_text(painter, text_box, txt) + def resizeEvent(self, _): + self.setColumnWidth(1, 150) + self.setColumnWidth(2, 100) + self.setColumnWidth(0, self.width() - 254) # Few pixels offset so the horizontal scrollbar does not appear diff --git a/TriblerGUI/widgets/searchresultspage.py b/TriblerGUI/widgets/searchresultspage.py index 3c255a85d12..ed6d3ca0c4e 100644 --- a/TriblerGUI/widgets/searchresultspage.py +++ b/TriblerGUI/widgets/searchresultspage.py @@ -2,8 +2,8 @@ from PyQt5.QtWidgets import QWidget -from TriblerGUI.widgets.channelview import ChannelContentsModel -from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS +from TriblerGUI.widgets.tablecontentmodel import SearchResultsContentModel +from TriblerGUI.widgets.triblertablecontrollers import SearchResultsTableViewController class SearchResultsPage(QWidget): @@ -13,36 +13,37 @@ class SearchResultsPage(QWidget): def __init__(self): QWidget.__init__(self) - self.search_results = {'channels': [], 'torrents': []} self.health_timer = None - self.show_torrents = True - self.show_channels = True self.query = None - self.model_mixed = None - self.model_channels = None - self.model_torrents = None - # TODO: use currentIndex from tab widget instead - self.tab_state = 'all' + self.controller = None + self.model = None def initialize_search_results_page(self): self.window().search_results_tab.initialize() self.window().search_results_tab.clicked_tab_button.connect(self.clicked_tab_button) - self.window().search_page_container.channel_entry_clicked.connect(self.window().on_channel_clicked) + self.model = SearchResultsContentModel() + self.controller = SearchResultsTableViewController(self.model, self.window().search_results_list, + self.window().num_search_results_label) + self.window().search_details_container.details_tab_widget.initialize_details_widget() + + self.window().search_results_list.on_torrent_clicked.connect(self.on_torrent_clicked) + + def on_torrent_clicked(self, torrent_info): + self.window().search_details_container.show() + self.window().search_details_container.details_tab_widget.update_with_torrent(torrent_info) def perform_search(self, query): self.query = query + self.model.reset() - self.model_mixed = ChannelContentsModel(parent=None, search_query=query) - self.model_channels = ChannelContentsModel(parent=None, search_query=query, search_type=u'channel') - self.model_torrents = ChannelContentsModel(parent=None, search_query=query, search_type=u'torrent') - self.switch_model() - - self.search_results = {'channels': [], 'torrents': []} self.window().num_search_results_label.setText("") + self.window().search_details_container.hide() trimmed_query = query if len(query) < 50 else "%s..." % query[:50] self.window().search_results_header_label.setText("Search results for: %s" % trimmed_query) + self.controller.load_search_results(query, 1, 50) + # Start the health timer that checks the health of the first five results """ if self.health_timer: @@ -60,42 +61,15 @@ def check_health_of_results(self): def set_columns_visibility(self, column_names, hide=True): for column_name in column_names: - self.window().search_page_container.torrents_table.setColumnHidden( + self.window().search_page_container.content_table.setColumnHidden( self.model_torrents.column_position[column_name], not hide) - def switch_model(self): - # Hide all columns that are hidden by at least one view - self.window().search_page_container.buttons_container.setHidden(True) - self.window().search_page_container.top_bar_container.setHidden(True) - - if self.tab_state == 'all': - self.window().search_page_container.set_model(self.model_mixed) - self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) - self.set_columns_visibility([u'subscribed', u'health', ACTION_BUTTONS], True) - self.window().search_page_container.details_tab_widget.setHidden(False) - - elif self.tab_state == 'channels': - self.window().search_page_container.set_model(self.model_channels) - self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) - self.set_columns_visibility([u'subscribed'], True) - self.window().search_page_container.details_tab_widget.setHidden(True) - - elif self.tab_state == 'torrents': - self.window().search_page_container.set_model(self.model_torrents) - self.set_columns_visibility([u'subscribed', u'health', u'commit_status', ACTION_BUTTONS], False) - self.set_columns_visibility([u'health', ACTION_BUTTONS], True) - self.window().search_page_container.details_tab_widget.setHidden(False) - - def clicked_tab_button(self, tab_button_name): - if tab_button_name == "search_results_all_button": - self.tab_state = 'all' - elif tab_button_name == "search_results_channels_button": - self.tab_state = 'channels' - elif tab_button_name == "search_results_torrents_button": - self.tab_state = 'torrents' - self.switch_model() - - def update_num_search_results(self): - self.window().num_search_results_label.setText("%d results" % - (len(self.search_results['channels']) + - len(self.search_results['torrents']))) + def clicked_tab_button(self, _): + if self.window().search_results_tab.get_selected_index() == 0: + self.model.type_filter = None + elif self.window().search_results_tab.get_selected_index() == 1: + self.model.type_filter = 'channel' + elif self.window().search_results_tab.get_selected_index() == 2: + self.model.type_filter = 'torrent' + + self.perform_search(self.query) diff --git a/TriblerGUI/widgets/subscribedchannelspage.py b/TriblerGUI/widgets/subscribedchannelspage.py index bfb449738f8..bdd9aa17bc2 100644 --- a/TriblerGUI/widgets/subscribedchannelspage.py +++ b/TriblerGUI/widgets/subscribedchannelspage.py @@ -1,10 +1,9 @@ from __future__ import absolute_import + from PyQt5.QtWidgets import QWidget -from TriblerGUI.defs import BUTTON_TYPE_NORMAL, BUTTON_TYPE_CONFIRM -from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog -from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.widgets.lazytableview import ACTION_BUTTONS +from TriblerGUI.widgets.tablecontentmodel import ChannelsContentModel +from TriblerGUI.widgets.triblertablecontrollers import ChannelsTableViewController class SubscribedChannelsPage(QWidget): @@ -14,48 +13,17 @@ class SubscribedChannelsPage(QWidget): def __init__(self): QWidget.__init__(self) - self.dialog = None self.request_mgr = None + self.model = None + self.controller = None def initialize(self): - self.window().add_subscription_button.clicked.connect(self.on_add_subscription_clicked) - - - container = self.window().subscribed_channels_container - container.initialize_model(subscribed=True) - container.channel_entry_clicked.connect(self.window().on_channel_clicked) - container.torrents_table.setColumnHidden(container.model.column_position[u'commit_status'], True) - container.torrents_table.setColumnHidden(container.model.column_position[u'health'], True) - container.torrents_table.setColumnHidden(container.model.column_position[ACTION_BUTTONS], True) - container.buttons_container.setHidden(True) - container.top_bar_container.setHidden(True) - container.details_tab_widget.setHidden(True) + self.model = ChannelsContentModel(subscribed=True) + self.controller = ChannelsTableViewController(self.model, self.window().subscribed_channels_list, + self.window().num_subscribed_channels_label, + self.window().subscribed_channels_filter_input) def load_subscribed_channels(self): - self.window().subscribed_channels_container.model.refresh() - - #FIXME: GigaChannel - def on_add_subscription_clicked(self): - self.dialog = ConfirmationDialog(self, "Add subscribed channel", - "Please enter the identifier of the channel you want to subscribe to below. " - "It can take up to a minute before the channel is visible in your list of " - "subscribed channels.", - [('ADD', BUTTON_TYPE_NORMAL), ('CANCEL', BUTTON_TYPE_CONFIRM)], - show_input=True) - self.dialog.dialog_widget.dialog_input.setPlaceholderText('Channel identifier') - self.dialog.button_clicked.connect(self.on_subscription_added) - self.dialog.show() - - #FIXME: GigaChannel - def on_subscription_added(self, action): - if action == 0: - self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("channels/subscribed/%s" % self.dialog.dialog_widget.dialog_input.text(), - self.on_channel_subscribed, method='PUT') - - self.dialog.close_dialog() - self.dialog = None - - def on_channel_subscribed(self, _): - pass + self.controller.model.reset() + self.controller.load_channels(1, 50) # Load the first 50 subscribed channels diff --git a/TriblerGUI/widgets/subscriptionswidget.py b/TriblerGUI/widgets/subscriptionswidget.py index 59a71a94a88..81526ffa0cb 100644 --- a/TriblerGUI/widgets/subscriptionswidget.py +++ b/TriblerGUI/widgets/subscriptionswidget.py @@ -15,6 +15,7 @@ class SubscriptionsWidget(QWidget): unsubscribed_channel = pyqtSignal(object) subscribed_channel = pyqtSignal(object) + credit_mining_toggled = pyqtSignal(bool) def __init__(self, parent): QWidget.__init__(self, parent) @@ -55,30 +56,28 @@ def update_subscribe_button(self, remote_response=None): if self.window().tribler_settings: # It could be that the settings are not loaded yet self.credit_mining_button.setHidden(not self.window().tribler_settings["credit_mining"]["enabled"]) - if self.channel_info["dispersy_cid"] in self.window().tribler_settings["credit_mining"]["sources"]: + if self.channel_info["public_key"] in self.window().tribler_settings["credit_mining"]["sources"]: self.credit_mining_button.setIcon(QIcon(QPixmap(get_image_path('credit_mining_yes.png')))) else: self.credit_mining_button.setIcon(QIcon(QPixmap(get_image_path('credit_mining_not.png')))) else: self.credit_mining_button.hide() - # Hide credit mining button until everything else works - self.credit_mining_button.hide() def on_subscribe_button_click(self): self.request_mgr = TriblerRequestManager() if int(self.channel_info["subscribed"]): - self.request_mgr.perform_request("channels/subscribed/%s" % - self.channel_info['dispersy_cid'], - self.on_channel_unsubscribed, method='DELETE') + self.request_mgr.perform_request("metadata/channels/%s" % + self.channel_info['public_key'], + self.on_channel_unsubscribed, data='subscribe=0', method='POST') else: - self.request_mgr.perform_request("channels/subscribed/%s" % - self.channel_info['dispersy_cid'], - self.on_channel_subscribed, method='PUT') + self.request_mgr.perform_request("metadata/channels/%s" % + self.channel_info['public_key'], + self.on_channel_subscribed, data='subscribe=1', method='POST') def on_channel_unsubscribed(self, json_result): if not json_result: return - if json_result["unsubscribed"]: + if json_result["success"]: self.unsubscribed_channel.emit(self.channel_info) self.channel_info["subscribed"] = False self.channel_info["votes"] -= 1 @@ -87,7 +86,7 @@ def on_channel_unsubscribed(self, json_result): def on_channel_subscribed(self, json_result): if not json_result or not self: return - if json_result["subscribed"]: + if json_result["success"]: self.subscribed_channel.emit(self.channel_info) self.channel_info["subscribed"] = True self.channel_info["votes"] += 1 @@ -95,28 +94,26 @@ def on_channel_subscribed(self, json_result): def on_credit_mining_button_click(self): old_sources = self.window().tribler_settings["credit_mining"]["sources"] - new_sources = [] if self.channel_info["dispersy_cid"] in old_sources else [self.channel_info["dispersy_cid"]] + new_sources = [] if self.channel_info["public_key"] in old_sources \ + else [self.channel_info["public_key"]] settings = {"credit_mining": {"sources": new_sources}} self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("settings", self.on_credit_mining_sources, - method='POST', data=json.dumps(settings)) + method='PUT', data=json.dumps(settings)) def on_credit_mining_sources(self, json_result): if not json_result: return if json_result["modified"]: old_source = next(iter(self.window().tribler_settings["credit_mining"]["sources"]), None) + if self.channel_info["public_key"] != old_source: + self.credit_mining_toggled.emit(True) + new_sources = [self.channel_info["public_key"]] + else: + self.credit_mining_toggled.emit(False) + new_sources = [] - new_sources = [self.channel_info["dispersy_cid"]] if self.channel_info["dispersy_cid"] != old_source else [] self.window().tribler_settings["credit_mining"]["sources"] = new_sources self.update_subscribe_button() - - channels_list = self.window().discovered_channels_list - for index, data_item in enumerate(channels_list.data_items): - if data_item[1]['dispersy_cid'] == old_source: - channel_item = channels_list.itemWidget(channels_list.item(index)) - if channel_item: - channel_item.subscriptions_widget.update_subscribe_button() - break diff --git a/TriblerGUI/widgets/tabbuttonpanel.py b/TriblerGUI/widgets/tabbuttonpanel.py index c9280c84f70..d3390f1a4d1 100644 --- a/TriblerGUI/widgets/tabbuttonpanel.py +++ b/TriblerGUI/widgets/tabbuttonpanel.py @@ -29,3 +29,9 @@ def deselect_all_buttons(self, except_select=None): button.setEnabled(True) button.setChecked(False) except_select.setChecked(True) + + def get_selected_index(self): + for index, button in enumerate(self.buttons): + if button.isChecked(): + return index + return -1 diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py new file mode 100644 index 00000000000..0136c6bd8dc --- /dev/null +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -0,0 +1,513 @@ +from __future__ import absolute_import, division + +from abc import abstractmethod + +from PyQt5.QtCore import QEvent, QModelIndex, QObject, QRect, QSize, Qt, pyqtSignal +from PyQt5.QtGui import QBrush, QColor, QIcon, QPainter, QPen +from PyQt5.QtWidgets import QStyle, QStyledItemDelegate + +from TriblerGUI.defs import ACTION_BUTTONS, COMMIT_STATUS_COMMITTED, COMMIT_STATUS_NEW, COMMIT_STATUS_TODELETE, \ + HEALTH_CHECKING, HEALTH_DEAD, HEALTH_ERROR, HEALTH_GOOD, HEALTH_MOOT, HEALTH_UNCHECKED +from TriblerGUI.utilities import get_image_path +from TriblerGUI.widgets.tableiconbuttons import DownloadIconButton, PlayIconButton + + +class TriblerButtonsDelegate(QStyledItemDelegate): + redraw_required = pyqtSignal() + + def __init__(self, parent=None): + QStyledItemDelegate.__init__(self, parent) + self.no_index = QModelIndex() + self.hoverrow = None + self.hover_index = None + self.controls = [] + + # We have to control if mouse is in the buttons box to add some tolerance for vertical mouse + # misplacement around the buttons. The button box effectively overlaps upper and lower rows. + # row 0 + # --------- <- tolerance zone + # row 1 |buttons| + # --------- <- tolerance zone + # row 2 + # button_box_extended_border_ration controls the thickness of the tolerance zone + self.button_box = QRect() + self.button_box_extended_border_ratio = float(0.3) + + def paint_empty_background(self, painter, option): + super(TriblerButtonsDelegate, self).paint(painter, option, self.no_index) + + def on_mouse_moved(self, pos, index): + # This method controls for which rows the buttons/box should be drawn + redraw = False + if self.hover_index != index: + self.hover_index = index + self.hoverrow = index.row() + if not self.button_box.contains(pos): + redraw = True + # Redraw when the mouse leaves the table + if index.row() == -1 and self.hoverrow != -1: + self.hoverrow = -1 + redraw = True + + for controls in self.controls: + redraw = controls.on_mouse_moved(pos, index) or redraw + + if redraw: + # TODO: optimize me to only redraw the rows that actually changed! + self.redraw_required.emit() + + @staticmethod + def split_rect_into_squares(rect, buttons): + r = rect + side_size = min(r.width() / len(buttons), r.height() - 2) + y_border = (r.height() - side_size) / 2 + for n, button in enumerate(buttons): + x = r.left() + n * side_size + y = r.top() + y_border + h = side_size + w = side_size + yield QRect(x, y, w, h), button + + def paint(self, painter, option, index): + # Draw 'hover' state highlight for every cell of a row + if index.row() == self.hoverrow: + option.state |= QStyle.State_MouseOver + if not self.paint_exact(painter, option, index): + # Draw the rest of the columns + super(TriblerButtonsDelegate, self).paint(painter, option, index) + + @abstractmethod + def paint_exact(self, painter, option, index): + pass + + def editorEvent(self, event, model, option, index): + for control in self.controls: + result = control.check_clicked(event, model, option, index) + if result: + return result + return False + + def createEditor(self, parent, option, index): + # Add null editor to action buttons column + if index.column() == index.model().column_position[ACTION_BUTTONS]: + return + + super(TriblerButtonsDelegate, self).createEditor(parent, option, index) + + +class SearchResultsDelegate(TriblerButtonsDelegate): + + def __init__(self, parent=None): + TriblerButtonsDelegate.__init__(self, parent) + self.subscribe_control = SubscribeToggleControl(ACTION_BUTTONS) + self.controls = [self.subscribe_control] + self.health_status_widget = HealthStatusDisplay() + + self.play_button = PlayIconButton() + self.download_button = DownloadIconButton() + self.ondemand_container = [self.play_button, self.download_button] + + def paint_exact(self, painter, option, index): + data_item = index.model().data_items[index.row()] + + # Draw the download controls + if ACTION_BUTTONS in index.model().column_position and \ + index.column() == index.model().column_position[ACTION_BUTTONS]: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + if data_item['type'] == 'channel': + # Draw subscribed widget + if index == self.hover_index: + self.subscribe_control.paint_hover(painter, option.rect, index) + else: + self.subscribe_control.paint(painter, option.rect, index, toggled=data_item['subscribed']) + else: + # When the cursor leaves the table, we must "forget" about the button_box + if self.hoverrow == -1: + self.button_box = QRect() + if index.row() == self.hoverrow: + extended_border_height = int(option.rect.height() * self.button_box_extended_border_ratio) + button_box_extended_rect = option.rect.adjusted(0, -extended_border_height, + 0, extended_border_height) + self.button_box = button_box_extended_rect + + active_buttons = [b for b in self.ondemand_container if b.should_draw(index)] + if active_buttons: + for rect, button in ChannelsButtonsDelegate.split_rect_into_squares( + button_box_extended_rect, active_buttons): + button.paint(painter, rect, index) + + return True + + # Draw 'category' column + elif u'category' in index.model().column_position and \ + index.column() == index.model().column_position[u'category']: + if data_item['type'] == 'channel': + category = data_item['type'] + else: + category = data_item[u'category'] + + # Draw empty cell as the background + self.paint_empty_background(painter, option) + CategoryLabel(category).paint(painter, option, index) + return True + + # Draw 'health' column + elif u'health' in index.model().column_position and index.column() == index.model().column_position[u'health']: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + if data_item['type'] == 'torrent': + self.health_status_widget.paint(painter, option.rect, index) + + return True + + +class ChannelsButtonsDelegate(TriblerButtonsDelegate): + + def __init__(self, parent=None): + TriblerButtonsDelegate.__init__(self, parent) + self.subscribe_control = SubscribeToggleControl(u'subscribed') + self.controls = [self.subscribe_control] + + def paint_exact(self, painter, option, index): + # Draw 'subscribed' column + if index.column() == index.model().column_position[u'subscribed']: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + data_item = index.model().data_items[index.row()] + + if index == self.hover_index: + self.subscribe_control.paint_hover(painter, option.rect, index) + else: + self.subscribe_control.paint(painter, option.rect, index, toggled=data_item['subscribed']) + + return True + + +class TorrentsButtonsDelegate(TriblerButtonsDelegate): + + def __init__(self, parent=None): + TriblerButtonsDelegate.__init__(self, parent) + + # On-demand buttons + self.play_button = PlayIconButton() + self.download_button = DownloadIconButton() + self.ondemand_container = [self.play_button, self.download_button] + self.commit_control = CommitStatusControl(u'status') + + self.controls = [self.play_button, self.download_button, self.commit_control] + + self.health_status_widget = HealthStatusDisplay() + + def paint_exact(self, painter, option, index): + # Draw 'health' column + if u'health' in index.model().column_position and index.column() == index.model().column_position[u'health']: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + self.health_status_widget.paint(painter, option.rect, index) + + return True + + # Draw buttons in the ACTION_BUTTONS column + elif ACTION_BUTTONS in index.model().column_position and \ + index.column() == index.model().column_position[ACTION_BUTTONS]: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + # When the cursor leaves the table, we must "forget" about the button_box + if self.hoverrow == -1: + self.button_box = QRect() + if index.row() == self.hoverrow: + extended_border_height = int(option.rect.height() * self.button_box_extended_border_ratio) + button_box_extended_rect = option.rect.adjusted(0, -extended_border_height, + 0, extended_border_height) + self.button_box = button_box_extended_rect + + active_buttons = [b for b in self.ondemand_container if b.should_draw(index)] + if active_buttons: + for rect, button in ChannelsButtonsDelegate.split_rect_into_squares( + button_box_extended_rect, active_buttons): + button.paint(painter, rect, index) + + return True + + # Draw 'commit_status' column + elif u'status' in index.model().column_position and index.column() == index.model().column_position[u'status']: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + + if index == self.hover_index: + self.commit_control.paint_hover(painter, option.rect, index) + else: + self.commit_control.paint(painter, option.rect, index) + + return True + + # Draw 'category' column + elif u'category' in index.model().column_position and \ + index.column() == index.model().column_position[u'category']: + # Draw empty cell as the background + self.paint_empty_background(painter, option) + CategoryLabel(index.model().data_items[index.row()]['category']).paint(painter, option, index) + return True + + +class CategoryLabel(QObject): + """ + A label that indicates the category of some metadata. + """ + + def __init__(self, category, parent=None): + QObject.__init__(self, parent=parent) + self.category = category + + def paint(self, painter, option, index): + painter.save() + + lines = QPen(QColor("#B5B5B5"), 1, Qt.SolidLine, Qt.RoundCap) + painter.setPen(lines) + + text_flags = Qt.AlignHCenter | Qt.AlignVCenter | Qt.TextSingleLine + text_box = painter.boundingRect(option.rect, text_flags, self.category) + + painter.drawText(text_box, text_flags, self.category) + bezel_thickness = 4 + bezel_box = QRect(text_box.left() - bezel_thickness, + text_box.top() - bezel_thickness, + text_box.width() + bezel_thickness * 2, + text_box.height() + bezel_thickness * 2) + + painter.setRenderHint(QPainter.Antialiasing) + painter.drawRoundedRect(bezel_box, 20, 80, mode=Qt.RelativeSize) + + painter.restore() + + +class ToggleControl(QObject): + """ + Column-level controls are stateless collections of methods for visualizing cell data and + triggering corresponding events. + """ + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + clicked = pyqtSignal(QModelIndex) + + def __init__(self, column_name, on_icon, off_icon, hover_icon, parent=None): + QObject.__init__(self, parent=parent) + self.on_icon = on_icon + self.off_icon = off_icon + self.hover_icon = hover_icon + self.column_name = column_name + self.last_index = QModelIndex() + + def paint(self, painter, rect, index, toggled=False): + icon = self.on_icon if toggled else self.off_icon + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + + def paint_hover(self, painter, rect, index): + icon = self.hover_icon + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + + def check_clicked(self, event, model, option, index): + if event.type() == QEvent.MouseButtonRelease and \ + index.model().column_position[self.column_name] == index.column(): + self.clicked.emit(index) + return True + return False + + def size_hint(self, option, index): + return self.size + + def on_mouse_moved(self, pos, index): + if self.last_index != index: + # Handle the case when the cursor leaves the table + if not index.model() or (index.model().column_position[self.column_name] == index.column()): + self.last_index = index + return True + return False + + +class SubscribeToggleControl(ToggleControl): + + def __init__(self, column_name, parent=None): + ToggleControl.__init__(self, column_name, + QIcon(get_image_path("subscribed_yes.png")), + QIcon(get_image_path("subscribed_not.png")), + QIcon(get_image_path("subscribed.png")), + parent=parent) + + +class CommitStatusControl(QObject): + # Column-level controls are stateless collections of methods for visualizing cell data and + # triggering corresponding events. + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + clicked = pyqtSignal(QModelIndex) + new_icon = QIcon(get_image_path("plus.svg")) + committed_icon = QIcon(get_image_path("check.svg")) + todelete_icon = QIcon(get_image_path("minus.svg")) + + delete_action_icon = QIcon(get_image_path("delete.png")) + restore_action_icon = QIcon(get_image_path("undo.svg")) + + def __init__(self, column_name, parent=None): + QObject.__init__(self, parent=parent) + self.column_name = column_name + self.rect = QRect() + self.last_index = QModelIndex() + + def paint(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + state = data_item[self.column_name] + icon = QIcon() + if state == COMMIT_STATUS_COMMITTED: + icon = self.committed_icon + elif state == COMMIT_STATUS_NEW: + icon = self.new_icon + elif state == COMMIT_STATUS_TODELETE: + icon = self.todelete_icon + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + self.rect = rect + + def paint_hover(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + if self.column_name not in data_item or data_item[self.column_name] == '': + return + state = data_item[self.column_name] + icon = QIcon() + + if state == COMMIT_STATUS_COMMITTED: + icon = self.delete_action_icon + elif state == COMMIT_STATUS_NEW: + icon = self.delete_action_icon + elif state == COMMIT_STATUS_TODELETE: + icon = self.restore_action_icon + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + icon.paint(painter, icon_rect) + self.rect = rect + + def check_clicked(self, event, model, option, index): + data_item = index.model().data_items[index.row()] + if event.type() == QEvent.MouseButtonRelease and \ + index.model().column_position[self.column_name] == index.column() and \ + data_item[self.column_name] != '': + self.clicked.emit(index) + return True + return False + + def size_hint(self, option, index): + return self.size + + def on_mouse_moved(self, pos, index): + if self.last_index != index: + # Handle the case when the cursor leaves the table + if not index.model(): + self.last_index = index + return True + elif index.model().column_position[self.column_name] == index.column(): + self.last_index = index + return True + return False + + +class HealthStatusDisplay(QObject): + indicator_side = 10 + indicator_border = 2 + health_colors = { + HEALTH_GOOD: QColor(Qt.green), + HEALTH_DEAD: QColor(Qt.red), + HEALTH_MOOT: QColor(Qt.yellow), + HEALTH_UNCHECKED: QColor("#B5B5B5"), + HEALTH_CHECKING: QColor(Qt.blue), + HEALTH_ERROR: QColor(Qt.cyan) + + } + + def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, alignment=Qt.AlignVCenter): + painter.save() + text_flags = Qt.AlignHCenter | alignment | Qt.TextSingleLine + text_box = painter.boundingRect(rect, text_flags, text) + painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap)) + if font: + painter.setFont(font) + + painter.drawText(text_box, text_flags, text) + painter.restore() + + def paint(self, painter, rect, index): + data_item = index.model().data_items[index.row()] + + if u'health' in data_item: + health = data_item[u'health'] + else: + health = HEALTH_UNCHECKED + + # ---------------- + # |b---b| | + # |b|i|b| 0S 0L | + # |b---b| | + # ---------------- + + r = rect + + # Indicator ellipse rectangle + y = r.top() + (r.height() - self.indicator_side) / 2 + x = r.left() + self.indicator_border + w = self.indicator_side + h = self.indicator_side + indicator_rect = QRect(x, y, w, h) + + # Paint indicator + painter.save() + painter.setBrush(QBrush(self.health_colors[health])) + painter.setPen(QPen(QColor(Qt.darkGray), 0, Qt.SolidLine, Qt.RoundCap)) + painter.drawEllipse(indicator_rect) + painter.restore() + + x = indicator_rect.left() + indicator_rect.width() + 2 * self.indicator_border + y = r.top() + w = r.width() - indicator_rect.width() - 2 * self.indicator_border + h = r.height() + text_box = QRect(x, y, w, h) + + # Paint status text, if necessary + if health in [HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_ERROR]: + self.draw_text(painter, text_box, health) + else: + seeders = int(data_item[u'num_seeders']) + leechers = int(data_item[u'num_leechers']) + + txt = u'S' + str(seeders) + u' L' + str(leechers) + + self.draw_text(painter, text_box, txt) diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py new file mode 100644 index 00000000000..aeb4c33a9e5 --- /dev/null +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -0,0 +1,225 @@ +from __future__ import absolute_import + +import time +from abc import abstractmethod + +from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSignal + +from TriblerGUI.defs import ACTION_BUTTONS, HEALTH_CHECKING, HEALTH_DEAD, HEALTH_ERROR, HEALTH_GOOD, HEALTH_MOOT +from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.utilities import format_size + + +class RemoteTableModel(QAbstractTableModel): + """ + The base model for the tables in the Tribler GUI. + It is specifically designed to fetch data from a remote data source, i.e. over a RESTful API. + """ + on_sort = pyqtSignal(str, bool) + columns = [] + + def __init__(self, parent=None): + super(RemoteTableModel, self).__init__(parent) + self.data_items = [] + self.item_load_batch = 50 + self.total_items = 0 # The total number of items without pagination + + @abstractmethod + def _get_remote_data(self, start, end, **kwargs): + # This must call self._on_new_items_received as a callback when data received + pass + + @abstractmethod + def _set_remote_data(self): + pass + + def reset(self): + self.beginResetModel() + self.data_items = [] + self.endResetModel() + + def sort(self, column, order): + self.reset() + self.on_sort.emit(self.columns[column], bool(order)) + + def add_items(self, new_data_items): + # If we want to block the signal like itemChanged, we must use QSignalBlocker object + old_end = self.rowCount() + new_end = self.rowCount() + len(new_data_items) + if old_end == new_end: + return + self.beginInsertRows(QModelIndex(), old_end, new_end - 1) + self.data_items.extend(new_data_items) + self.endInsertRows() + + +class TriblerContentModel(RemoteTableModel): + column_headers = [] + column_width = {} + column_flags = {} + column_display_filters = {} + + def __init__(self): + RemoteTableModel.__init__(self, parent=None) + self.data_items = [] + self.column_position = {name: i for i, name in enumerate(self.columns)} + + def headerData(self, num, orientation, role=None): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self.column_headers[num] + + def _get_remote_data(self, start, end, **kwargs): + pass + + def _set_remote_data(self): + pass + + def rowCount(self, parent=QModelIndex()): + return len(self.data_items) + + def columnCount(self, parent=QModelIndex()): + return len(self.columns) + + def flags(self, index): + return self.column_flags[self.columns[index.column()]] + + def data(self, index, role): + if role == Qt.DisplayRole: + column = self.columns[index.column()] + data = self.data_items[index.row()][column] if column in self.data_items[index.row()] else u'UNDEFINED' + return self.column_display_filters.get(column, str(data))(data) \ + if column in self.column_display_filters else data + + +class SearchResultsContentModel(TriblerContentModel): + """ + Model for a list that shows search results. + """ + columns = [u'category', u'name', u'health', ACTION_BUTTONS] + column_headers = [u'Category', u'Name', u'health', u''] + column_flags = { + u'category': Qt.ItemIsEnabled, + u'name': Qt.ItemIsEnabled, + u'health': Qt.ItemIsEnabled, + ACTION_BUTTONS: Qt.ItemIsEnabled + } + + def __init__(self): + TriblerContentModel.__init__(self) + self.type_filter = None + + +class ChannelsContentModel(TriblerContentModel): + """ + This model represents a list of channels that can be displayed in a table view. + """ + columns = [u'name', u'torrents', u'subscribed'] + column_headers = [u'Channel name', u'Torrents', u''] + column_flags = { + u'name': Qt.ItemIsEnabled, + u'torrents': Qt.ItemIsEnabled, + u'subscribed': Qt.ItemIsEnabled, + ACTION_BUTTONS: Qt.ItemIsEnabled + } + + def __init__(self, subscribed=False): + TriblerContentModel.__init__(self) + self.subscribed = subscribed + + +class TorrentsContentModel(TriblerContentModel): + columns = [u'category', u'name', u'size', u'health', ACTION_BUTTONS] + column_headers = [u'Category', u'Name', u'Size', u'Health', u''] + column_flags = { + u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'size': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'health': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable + } + + column_display_filters = { + u'size': lambda data: format_size(float(data)), + } + + def __init__(self, channel_pk=''): + TriblerContentModel.__init__(self) + + self.channel_pk = channel_pk + + # This dict keeps the mapping of infohashes in data_items to indexes + # It is used by Health Checker to track the health status updates across model refreshes + self.infohashes = {} + self.last_health_check_ts = {} + + def reset(self): + # Health Checker related + # Infohash to data_items mapping should be cleaned each time we refresh the model + self.infohashes.clear() + super(TorrentsContentModel, self).reset() + + def update_torrent_health(self, infohash, seeders, leechers, health): + if infohash in self.infohashes: + row = self.infohashes[infohash] + self.data_items[row][u'num_seeders'] = seeders + self.data_items[row][u'num_leechers'] = leechers + self.data_items[row][u'health'] = health + index = self.index(row, self.column_position[u'health']) + self.dataChanged.emit(index, index, []) + + def check_torrent_health(self, index): + timeout = 15 + infohash = self.data_items[index.row()][u'infohash'] + + # TODO: move timeout check to the endpoint + if infohash in self.last_health_check_ts and \ + (time.time() - self.last_health_check_ts[infohash] < timeout): + return + self.last_health_check_ts[infohash] = time.time() + + def on_cancel_health_check(): + pass + + def on_health_response(response): + self.last_health_check_ts[infohash] = time.time() + total_seeders = 0 + total_leechers = 0 + + if not response or 'error' in response: + self.update_torrent_health(infohash, 0, 0, HEALTH_ERROR) # Just set the health to 0 seeders, 0 leechers + return + + for _, status in response['health'].iteritems(): + if 'error' in status: + continue # Timeout or invalid status + total_seeders += int(status['seeders']) + total_leechers += int(status['leechers']) + + if total_seeders > 0: + health = HEALTH_GOOD + elif total_leechers > 0: + health = HEALTH_MOOT + else: + health = HEALTH_DEAD + + self.update_torrent_health(infohash, total_seeders, total_leechers, health) + + self.data_items[index.row()][u'health'] = HEALTH_CHECKING + index_upd = self.index(index.row(), self.column_position[u'health']) + self.dataChanged.emit(index_upd, index_upd, []) + health_request_mgr = TriblerRequestManager() + health_request_mgr.perform_request("torrents/%s/health?timeout=%s&refresh=%d" % + (infohash, timeout, 1), + on_health_response, capture_errors=False, priority="LOW", + on_cancel=on_cancel_health_check) + + +class MyTorrentsContentModel(TorrentsContentModel): + columns = [u'category', u'name', u'size', u'status'] + column_headers = [u'Category', u'Name', u'Size', u''] + column_flags = { + u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'size': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'status': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + } diff --git a/TriblerGUI/widgets/tableiconbuttons.py b/TriblerGUI/widgets/tableiconbuttons.py new file mode 100644 index 00000000000..41ed86e5d08 --- /dev/null +++ b/TriblerGUI/widgets/tableiconbuttons.py @@ -0,0 +1,66 @@ +from __future__ import absolute_import, division + +from PyQt5.QtCore import QEvent, QModelIndex, QObject, QRect, QSize, pyqtSignal +from PyQt5.QtGui import QIcon + +from TriblerGUI.utilities import get_image_path + + +class IconButton(QObject): + icon = QIcon() + icon_border_ratio = float(0.1) + clicked = pyqtSignal(QModelIndex) + + icon_border = 4 + icon_size = 16 + h = icon_size + 2 * icon_border + w = h + size = QSize(w, h) + + def __init__(self, parent=None): + super(IconButton, self).__init__(parent=parent) + # rect property contains the active zone for the button + self.rect = QRect() + self.icon_rect = QRect() + self.icon_mode = QIcon.Normal + + def should_draw(self, index): + return True + + def paint(self, painter, rect, index): + # Update button activation rect from the drawing call + self.rect = rect + + x = rect.left() + (rect.width() - self.w) / 2 + y = rect.top() + (rect.height() - self.h) / 2 + icon_rect = QRect(x, y, self.w, self.h) + + self.icon.paint(painter, icon_rect, mode=self.icon_mode) + + def check_clicked(self, event, model, option, index): + if event.type() == QEvent.MouseButtonRelease and self.rect.contains(event.pos()): + self.clicked.emit(index) + return True + return False + + def on_mouse_moved(self, pos, index): + old_icon_mode = self.icon_mode + if self.rect.contains(pos): + self.icon_mode = QIcon.Selected + else: + self.icon_mode = QIcon.Normal + return old_icon_mode != self.icon_mode + + def size_hint(self, option, index): + return self.size + + +class DownloadIconButton(IconButton): + icon = QIcon(get_image_path("downloads.png")) + + +class PlayIconButton(IconButton): + icon = QIcon(get_image_path("play.png")) + + def should_draw(self, index): + return index.model().data_items[index.row()][u'category'] == u'Video' diff --git a/TriblerGUI/widgets/torrentdetailscontainer.py b/TriblerGUI/widgets/torrentdetailscontainer.py new file mode 100644 index 00000000000..d6c7ee85c39 --- /dev/null +++ b/TriblerGUI/widgets/torrentdetailscontainer.py @@ -0,0 +1,13 @@ +from __future__ import absolute_import + +from PyQt5 import uic +from PyQt5.QtWidgets import QWidget + +from TriblerGUI.utilities import get_ui_file_path + + +class TorrentDetailsContainer(QWidget): + + def __init__(self, parent): + QWidget.__init__(self, parent) + uic.loadUi(get_ui_file_path('torrent_details_container.ui'), self) diff --git a/TriblerGUI/widgets/torrentdetailstabwidget.py b/TriblerGUI/widgets/torrentdetailstabwidget.py index 64edaf5f301..3cd3799731d 100644 --- a/TriblerGUI/widgets/torrentdetailstabwidget.py +++ b/TriblerGUI/widgets/torrentdetailstabwidget.py @@ -1,17 +1,16 @@ from __future__ import absolute_import + import logging from PyQt5.QtCore import pyqtSignal -from PyQt5.QtWidgets import QLabel -from PyQt5.QtWidgets import QTabWidget -from PyQt5.QtWidgets import QTreeWidget -from PyQt5.QtWidgets import QTreeWidgetItem +from PyQt5.QtWidgets import QLabel, QTabWidget, QTreeWidget, QTreeWidgetItem -from Tribler.Core.Modules.restapi.util import HEALTH_CHECKING +from TriblerGUI.defs import HEALTH_CHECKING from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.utilities import format_size from TriblerGUI.widgets.ellipsebutton import EllipseButton + class TorrentDetailsTabWidget(QTabWidget): health_check_clicked = pyqtSignal(dict) """ @@ -19,7 +18,6 @@ class TorrentDetailsTabWidget(QTabWidget): includes the generic info about the torrent, files and trackers. """ - #TODO: rewrite this as a view into ChannelContentsModel def __init__(self, parent): QTabWidget.__init__(self, parent) self.torrent_info = None @@ -116,7 +114,6 @@ def update_with_torrent(self, torrent_info): def on_check_health_clicked(self, timeout=15): self.health_check_clicked.emit(self.torrent_info) - def update_health(self, seeders, leechers, health=None): try: if seeders > 0: diff --git a/TriblerGUI/widgets/torrentslistwidget.py b/TriblerGUI/widgets/torrentslistwidget.py new file mode 100644 index 00000000000..d55e4184e12 --- /dev/null +++ b/TriblerGUI/widgets/torrentslistwidget.py @@ -0,0 +1,38 @@ +from __future__ import absolute_import + +from PyQt5 import uic +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import QWidget + +from TriblerGUI.utilities import get_ui_file_path +from TriblerGUI.widgets.torrentdetailstabwidget import TorrentDetailsTabWidget + + +class TorrentsListWidget(QWidget): + on_torrent_clicked = pyqtSignal(dict) + + def __init__(self, parent=None): + QWidget.__init__(self, parent=parent) + uic.loadUi(get_ui_file_path('torrents_list.ui'), self) + + self.model = None + self.details_tab_widget = None + + self.details_tab_widget = self.findChild(TorrentDetailsTabWidget, "details_tab_widget") + self.details_tab_widget.initialize_details_widget() + self.details_tab_widget.health_check_clicked.connect(self.on_details_tab_widget_health_check_clicked) + + def on_details_tab_widget_health_check_clicked(self, torrent_info): + infohash = torrent_info[u'infohash'] + if infohash in self.model.infohashes: + self.model.check_torrent_health(self.model.index(self.model.infohashes[infohash], 0)) + + # def on_table_item_clicked(self, item): + # if item.column() == self.content_table.model().column_position[ACTION_BUTTONS] + # return + # table_entry = self.content_table.model().data_items[item.row()] + # if table_entry['type'] == u'torrent': + # self.details_tab_widget.update_with_torrent(table_entry) + # self.model.check_torrent_health(item) + # elif table_entry['type'] == u'channel': + # self.on_torrent_clicked.emit(table_entry) diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py new file mode 100644 index 00000000000..4adb780b006 --- /dev/null +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -0,0 +1,252 @@ +""" +This file contains various controllers for table views. +The responsibility of the controller is to populate the table view with some data, contained in a specific model. +""" +from __future__ import absolute_import + +from TriblerGUI.tribler_request_manager import TriblerRequestManager + + +class TriblerTableViewController(object): + """ + Base controller for a table view that displays some data. + """ + + def __init__(self, model, table_view): + self.model = model + self.model.on_sort.connect(self._on_view_sort) + self.table_view = table_view + self.table_view.setModel(self.model) + self.table_view.verticalScrollBar().valueChanged.connect(self._on_list_scroll) + self.request_mgr = None + + def _on_list_scroll(self, event): + pass + + def _on_view_sort(self, column, ascending): + pass + + def _get_sort_parameters(self): + """ + Return a tuple (column_name, sort_asc) that indicates the sorting column/order of the table view. + """ + sort_by = self.model.columns[self.table_view.horizontalHeader().sortIndicatorSection()] + sort_asc = self.table_view.horizontalHeader().sortIndicatorOrder() + return sort_by, sort_asc + + +class SearchResultsTableViewController(TriblerTableViewController): + """ + Controller for the table view that handles search results. + """ + + def __init__(self, model, table_view, num_search_results_label=None): + TriblerTableViewController.__init__(self, model, table_view) + self.num_search_results_label = num_search_results_label + self.query = None + + def _on_view_sort(self, column, ascending): + self.model.reset() + self.load_search_results(1, 50) + + def _on_list_scroll(self, event): + if self.table_view.verticalScrollBar().value() == self.table_view.verticalScrollBar().maximum() and \ + self.model.data_items: # workaround for duplicate calls to _on_list_scroll on view creation + self.load_search_results(self.query) + + def load_search_results(self, query, start=None, end=None): + """ + Fetch search results for a given query. + """ + self.query = query + + if not start and not end: + start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch + + sort_by, sort_asc = self._get_sort_parameters() + + self.request_mgr = TriblerRequestManager() + self.request_mgr.perform_request( + "search?q=%s&first=%i&last=%i" % (query, start, end) + + ('&sort_by=%s' % sort_by) + + ('&sort_asc=%d' % sort_asc) + + ('&type=%s' % ('' if not self.model.type_filter else self.model.type_filter)), + self.on_search_results) + + def on_search_results(self, response): + if not response: + return + + self.model.total_items = response['total'] + + if self.num_search_results_label: + self.num_search_results_label.setText("%d results" % response['total']) + + if response['first'] >= self.model.rowCount(): + self.model.add_items(response['results']) + + +class ChannelsTableViewController(TriblerTableViewController): + """ + This class manages a list with channels. + """ + + def __init__(self, model, table_view, num_channels_label=None, filter_input=None): + TriblerTableViewController.__init__(self, model, table_view) + self.num_channels_label = num_channels_label + self.filter_input = filter_input + + if self.filter_input: + self.filter_input.textChanged.connect(self._on_filter_input_change) + + def _on_filter_input_change(self, text): + self.model.reset() + self.load_channels(1, 50) + + def _on_view_sort(self, column, ascending): + self.model.reset() + self.load_channels(1, 50) + + def _on_list_scroll(self, event): + if self.table_view.verticalScrollBar().value() == self.table_view.verticalScrollBar().maximum() and \ + self.model.data_items: # workaround for duplicate calls to _on_list_scroll on view creation + self.load_channels() + + def load_channels(self, start=None, end=None): + """ + Fetch various channels. + """ + if not start and not end: + start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch + + if self.filter_input and self.filter_input.text().lower(): + filter_text = self.filter_input.text().lower() + else: + filter_text = '' + + sort_by, sort_asc = self._get_sort_parameters() + + self.request_mgr = TriblerRequestManager() + self.request_mgr.perform_request( + "metadata/channels?first=%i&last=%i" % (start, end) + + ('&sort_by=%s' % sort_by) + + ('&sort_asc=%d' % sort_asc) + + ('&filter=%s' % filter_text) + + ('&subscribed=%d' % int(self.model.subscribed)), + self.on_channels) + + def on_channels(self, response): + if not response: + return + + self.model.total_items = response['total'] + + if self.num_channels_label: + self.num_channels_label.setText("%d items" % response['total']) + + if response['first'] >= self.model.rowCount(): + self.model.add_items(response['channels']) + + +class TorrentsTableViewController(TriblerTableViewController): + """ + This class manages a list with torrents. + """ + + def __init__(self, model, torrents_container, num_torrents_label=None, filter_input=None): + TriblerTableViewController.__init__(self, model, torrents_container.content_table) + self.num_torrents_label = num_torrents_label + self.filter_input = filter_input + + if self.filter_input: + self.filter_input.textChanged.connect(self._on_filter_input_change) + + def _on_filter_input_change(self, text): + self.model.reset() + self.load_torrents(1, 50) + + def _on_view_sort(self, column, ascending): + self.model.reset() + self.load_torrents(1, 50) + + def _on_list_scroll(self, event): + if self.table_view.verticalScrollBar().value() == self.table_view.verticalScrollBar().maximum() and \ + self.model.data_items: # workaround for duplicate calls to _on_list_scroll on view creation + self.load_torrents() + + def load_torrents(self, start=None, end=None): + """ + Fetch various torrents. + """ + if not start and not end: + start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch + + if self.filter_input and self.filter_input.text().lower(): + filter_text = self.filter_input.text().lower() + else: + filter_text = '' + + sort_by, sort_asc = self._get_sort_parameters() + + self.request_mgr = TriblerRequestManager() + self.request_mgr.perform_request( + "metadata/channels/%s/torrents?first=%i&last=%i" % (self.model.channel_pk, start, end) + + ('&sort_by=%s' % sort_by) + + ('&sort_asc=%d' % sort_asc) + + ('&filter=%s' % filter_text), + self.on_torrents) + + def on_torrents(self, response): + if not response: + return + + self.model.total_items = response['total'] + + if self.num_torrents_label: + self.num_torrents_label.setText("%d items" % response['total']) + + if response['first'] >= self.model.rowCount(): + self.model.add_items(response['torrents']) + + +class MyTorrentsTableViewController(TorrentsTableViewController): + """ + This class manages the list with your the torrents in your own channel. + """ + + def load_torrents(self, start=None, end=None): + """ + Fetch various torrents. + """ + if not start and not end: + start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch + + if self.filter_input and self.filter_input.text().lower(): + filter_text = self.filter_input.text().lower() + else: + filter_text = '' + + sort_by, sort_asc = self._get_sort_parameters() + + self.request_mgr = TriblerRequestManager() + self.request_mgr.perform_request( + "mychannel/torrents?first=%i&last=%i" % (start, end) + + ('&sort_by=%s' % sort_by) + + ('&sort_asc=%d' % sort_asc) + + ('&filter=%s' % filter_text), + self.on_torrents) + + def on_torrents(self, response): + if not response: + return + + self.model.total_items = response['total'] + + if self.num_torrents_label: + self.num_torrents_label.setText("%d items" % response['total']) + + if response['first'] >= self.model.rowCount(): + self.model.add_items(response['torrents']) + + self.table_view.window().dirty_channel_status_bar.setHidden(not response['dirty']) + self.table_view.window().edit_channel_commit_button.setEnabled(response['dirty']) From a5cd5f43306ff692462d0b58ba97602aa0299fd0 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Tue, 8 Jan 2019 14:51:39 +0100 Subject: [PATCH 07/38] Refactored Tribler REST API endpoints To match the GUI refactor --- .../OrmBindings/channel_metadata.py | 73 ++- .../MetadataStore/OrmBindings/metadata.py | 25 + .../OrmBindings/torrent_metadata.py | 53 ++ .../Core/Modules/restapi/channels/__init__.py | 3 - .../channels/base_channels_endpoint.py | 88 ---- .../channels/channels_discovered_endpoint.py | 158 ------ .../restapi/channels/channels_endpoint.py | 18 - .../channels/channels_playlists_endpoint.py | 359 ------------- .../channels/channels_popular_endpoint.py | 61 --- .../restapi/channels/channels_rss_endpoint.py | 190 ------- .../channels_subscription_endpoint.py | 190 ------- .../channels/channels_torrents_endpoint.py | 360 ------------- .../restapi/channels/my_channel_endpoint.py | 116 ----- .../Core/Modules/restapi/metadata_endpoint.py | 355 +++++++++++++ .../Modules/restapi/mychannel_endpoint.py | 197 +++++++ Tribler/Core/Modules/restapi/root_endpoint.py | 31 +- .../Core/Modules/restapi/search_endpoint.py | 242 +++------ .../Modules/restapi/torrentinfo_endpoint.py | 175 ------- .../Core/Modules/restapi/torrents_endpoint.py | 350 ------------- Tribler/Core/Session.py | 8 +- Tribler/Core/simpledefs.py | 1 + .../MetadataStore/test_channel_metadata.py | 39 +- .../MetadataStore/test_torrent_metadata.py | 24 + .../Core/Modules/RestApi/Channels/__init__.py | 3 - .../test_channels_discovered_endpoint.py | 209 -------- .../Channels/test_channels_endpoint.py | 123 ----- .../test_channels_playlist_endpoint.py | 488 ------------------ .../test_channels_popular_endpoint.py | 40 -- .../Channels/test_channels_rss_endpoint.py | 178 ------- .../test_channels_subscription_endpoint.py | 218 -------- .../test_channels_torrents_endpoint.py | 418 --------------- .../Channels/test_my_channel_endpoints.py | 97 ---- .../Modules/RestApi/test_events_endpoint.py | 15 +- .../Modules/RestApi/test_market_endpoint.py | 5 +- .../Modules/RestApi/test_metadata_endpoint.py | 284 ++++++++++ .../RestApi/test_mychannel_endpoint.py | 243 +++++++++ .../Modules/RestApi/test_search_endpoint.py | 121 +---- .../RestApi/test_torrentinfo_endpoint.py | 122 ----- .../Modules/RestApi/test_torrents_endpoint.py | 270 ---------- Tribler/community/gigachannel/community.py | 2 +- 40 files changed, 1409 insertions(+), 4543 deletions(-) delete mode 100644 Tribler/Core/Modules/restapi/channels/__init__.py delete mode 100644 Tribler/Core/Modules/restapi/channels/base_channels_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_playlists_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_popular_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_rss_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py create mode 100644 Tribler/Core/Modules/restapi/metadata_endpoint.py create mode 100644 Tribler/Core/Modules/restapi/mychannel_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/torrentinfo_endpoint.py delete mode 100644 Tribler/Core/Modules/restapi/torrents_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/__init__.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_playlist_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_popular_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_rss_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py create mode 100644 Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py create mode 100644 Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py delete mode 100644 Tribler/Test/Core/Modules/RestApi/test_torrents_endpoint.py diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 2b94e103b07..875159e2112 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -1,16 +1,19 @@ from __future__ import absolute_import import os +from binascii import hexlify from datetime import datetime -from libtorrent import file_storage, add_files, create_torrent, set_piece_hashes, bencode, torrent_info + +from libtorrent import add_files, bencode, create_torrent, file_storage, set_piece_hashes, torrent_info import lz4.frame + from pony import orm from pony.orm import db_session, raw_sql, select -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import TODELETE, NEW, COMMITTED, PUBLIC_KEY_LEN -from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload, CHANNEL_TORRENT -from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelNameError +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import COMMITTED, NEW, PUBLIC_KEY_LEN, TODELETE +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, ChannelMetadataPayload +from Tribler.Core.exceptions import DuplicateChannelNameError, DuplicateTorrentFileError from Tribler.pyipv8.ipv8.database import database_blob CHANNEL_DIR_NAME_LENGTH = 32 # Its not 40 so it could be distinguished from infohash @@ -214,13 +217,13 @@ def commit_channel_torrent(self): return new_infohash @db_session - def has_torrent(self, infohash): + def get_torrent(self, infohash): """ - Check whether this channel contains the torrent with a provided infohash. + Return the torrent with a provided infohash. :param infohash: The infohash of the torrent to search for - :return: True if the torrent exists in the channel, else False + :return: TorrentMetadata if the torrent exists in the channel, else None """ - return db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) is not None + return db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) @db_session def add_torrent_to_channel(self, tdef, extra_info): @@ -229,7 +232,7 @@ def add_torrent_to_channel(self, tdef, extra_info): :param tdef: The torrent definition file of the torrent to add :param extra_info: Optional extra info to add to the torrent """ - if self.has_torrent(tdef.get_infohash()): + if self.get_torrent(tdef.get_infohash()): raise DuplicateTorrentFileError() if extra_info: @@ -287,22 +290,23 @@ def contents_len(self): return orm.count(self.contents) @db_session - def delete_torrent_from_channel(self, infohash): + def delete_torrent(self, infohash): """ Remove a torrent from this channel. Obsolete blob files are never deleted except on defragmentation of the channel. :param infohash: The infohash of the torrent to remove :return True if deleted, False if no MD with the given infohash found """ - if self.has_torrent(infohash): - torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) - else: + torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) + if not torrent_metadata: return False + if torrent_metadata.status == NEW: # Uncommited metadata. Delete immediately torrent_metadata.delete() else: torrent_metadata.status = TODELETE + return True @db_session @@ -312,7 +316,7 @@ def cancel_torrent_deletion(self, infohash): :param infohash: The infohash of the torrent to act upon :return True if deleteion cancelled, False if no MD with the given infohash found """ - if self.has_torrent(infohash): + if self.get_torrent(infohash): torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) else: return False @@ -366,15 +370,18 @@ def extend_to_bitmask(txt): @classmethod @db_session - def get_random_subscribed_channels(cls, limit): + def get_random_channels(cls, limit, subscribed=False): """ Fetch up to some limit of torrents from this channel :param limit: the maximum amount of torrents to fetch + :param subscribed: whether we want random channels we are subscribed to :return: the subset of random channels we are subscribed to :rtype: list """ - return db.ChannelMetadata.select(lambda g: g.subscribed).random(limit) + if subscribed: + return db.ChannelMetadata.select(lambda g: g.subscribed).random(limit) + return db.ChannelMetadata.select().random(limit) @db_session def get_random_torrents(self, limit): @@ -389,4 +396,38 @@ def remove_contents(self): def get_updated_channels(cls): return select(g for g in cls if g.subscribed and (g.local_version < g.timestamp)) + @classmethod + @db_session + def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, subscribed=False): + """ + Get some channels. Optionally sort the results by a specific field, or filter the channels based + on a keyword/whether you are subscribed to it. + :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates + the total number of results, regardless the passed first/last parameter. + """ + pony_query = ChannelMetadata.get_entries_query( + ChannelMetadata, sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + + # Filter subscribed/non-subscribed + if subscribed: + pony_query = pony_query.where(subscribed=subscribed) + + total_results = pony_query.count() + + return pony_query[first-1:last], total_results + + @db_session + def to_simple_dict(self): + """ + Return a basic dictionary with information about the channel. + """ + return { + "id": self.rowid, + "public_key": hexlify(self.public_key), + "name": self.title, + "torrents": self.contents_len, + "subscribed": self.subscribed, + "votes": self.votes + } + return ChannelMetadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index d1696f18395..1aca8125e64 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -4,6 +4,7 @@ from datetime import datetime from pony import orm +from pony.orm import db_session, select, desc from Tribler.Core.Modules.MetadataStore.serialization import MetadataPayload, DeletedMetadataPayload, TYPELESS, DELETED from Tribler.Core.exceptions import InvalidSignatureException @@ -145,4 +146,28 @@ def from_payload(cls, payload): def from_dict(cls, dct): return cls(**dct) + @classmethod + @db_session + def get_entries_query(cls, metadata_type, sort_by=None, sort_asc=True, query_filter=None): + """ + Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based + on a keyword/whether you are subscribed to it. + :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates + the total number of results, regardless the passed first/last parameter. + """ + # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! + pony_query = select(g for g in metadata_type) + + # Filter the results on a keyword or some keywords + if query_filter: + pony_query = metadata_type.search_keyword(query_filter + "*", lim=1000) + + # Sort the query + if sort_by: + sort_expression = "g." + sort_by + sort_expression = sort_expression if sort_asc else desc(sort_expression) + pony_query = pony_query.sort_by(sort_expression) + + return pony_query + return Metadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 2fbfe1610da..7d2b2a63822 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from binascii import hexlify from datetime import datetime from pony import orm @@ -75,4 +76,56 @@ def get_auto_complete_terms(cls, keyword, max_terms, limit=100): all_terms.add(term) return list(all_terms) + @classmethod + @db_session + def get_random_torrents(cls, limit): + """ + Return some random torrents from the database. + """ + return TorrentMetadata.select().where(metadata_type=REGULAR_TORRENT).random(limit) + + @classmethod + @db_session + def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, channel_pk=False): + """ + Get some torrents. Optionally sort the results by a specific field, or filter the channels based + on a keyword/whether you are subscribed to it. + :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates + the total number of results, regardless the passed first/last parameter. + """ + pony_query = TorrentMetadata.get_entries_query( + TorrentMetadata, sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + + # We only want torrents, not channel torrents + pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) + + # Filter on channel + if channel_pk: + pony_query = pony_query.where(public_key=channel_pk) + + total_results = pony_query.count() + + return pony_query[first - 1:last], total_results + + @db_session + def to_simple_dict(self, include_status=False): + """ + Return a basic dictionary with information about the channel. + """ + simple_dict = { + "id": self.rowid, + "name": self.title, + "infohash": hexlify(self.infohash), + "size": self.size, + "category": self.tags, + "num_seeders": self.health.seeders, + "num_leechers": self.health.leechers, + "last_tracker_check": self.health.last_check + } + + if include_status: + simple_dict['status'] = self.status + + return simple_dict + return TorrentMetadata diff --git a/Tribler/Core/Modules/restapi/channels/__init__.py b/Tribler/Core/Modules/restapi/channels/__init__.py deleted file mode 100644 index 1724474da8f..00000000000 --- a/Tribler/Core/Modules/restapi/channels/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains endpoints to manage items in a given channel such as torrents, rss feeds and playlists. -""" diff --git a/Tribler/Core/Modules/restapi/channels/base_channels_endpoint.py b/Tribler/Core/Modules/restapi/channels/base_channels_endpoint.py deleted file mode 100644 index 39acc664866..00000000000 --- a/Tribler/Core/Modules/restapi/channels/base_channels_endpoint.py +++ /dev/null @@ -1,88 +0,0 @@ -from __future__ import absolute_import - -import logging -import time -from twisted.web import http, resource - -from Tribler.Core.simpledefs import NTFY_CHANNELCAST -import Tribler.Core.Utilities.json_util as json -from Tribler.dispersy.exception import CommunityNotFoundException - -UNKNOWN_CHANNEL_RESPONSE_MSG = "the channel with the provided cid is not known" -UNAUTHORIZED_RESPONSE_MSG = "you are not authorized to perform this request" - - -class BaseChannelsEndpoint(resource.Resource): - """ - This class contains some utility methods to work with raw channels from the database. - All endpoints that are using the database, should derive from this class. - """ - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - self._logger = logging.getLogger(self.__class__.__name__) - - @staticmethod - def return_404(request, message=UNKNOWN_CHANNEL_RESPONSE_MSG): - """ - Returns a 404 response code if your channel has not been created. - """ - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": message}) - - def return_500(self, request, exception): - self._logger.exception(exception) - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({ - u"error": { - u"handled": True, - u"code": exception.__class__.__name__, - u"message": exception.message - } - }) - - @staticmethod - def return_401(request, message=UNAUTHORIZED_RESPONSE_MSG): - """ - Returns a 401 response code if you are not authorized to perform a specific request. - """ - request.setResponseCode(http.UNAUTHORIZED) - return json.dumps({"error": message}) - - def get_channel_from_db(self, cid): - """ - Returns information about the channel from the database. Returns None if the channel with given cid - does not exist. - """ - channels_list = self.channel_db_handler.getChannelsByCID([cid]) - return channels_list[0] if len(channels_list) > 0 else None - - def get_my_channel_object(self): - """ - Returns the Channel object associated with a channel that is used to manage rss feeds. - """ - my_channel_id = self.channel_db_handler.getMyChannelId() - return self.session.lm.channel_manager.get_my_channel(my_channel_id) - - def vote_for_channel(self, cid, vote): - """ - Make a vote in the channel specified by the cid. Returns a deferred that fires when the vote is done. - """ - # TODO remove when we remove Dispersy - from Tribler.community.allchannel.community import AllChannelCommunity - for community in self.session.get_dispersy_instance().get_communities(): - if isinstance(community, AllChannelCommunity): - return community.disp_create_votecast(cid, vote, int(time.time())) - - def get_community_for_channel_id(self, channel_id): - """ - Returns a Dispersy community from the given channel id. The Community object can be used to delete/add torrents - or modify playlists in a specific channel. - """ - dispersy_cid = str(self.channel_db_handler.getDispersyCIDFromChannelId(channel_id)) - try: - return self.session.get_dispersy_instance().get_community(dispersy_cid) - except CommunityNotFoundException: - return None diff --git a/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py deleted file mode 100644 index 76a54ee2f07..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_discovered_endpoint.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import absolute_import - -from pony.orm import db_session -from twisted.web import http - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.channels.channels_playlists_endpoint import ChannelsPlaylistsEndpoint -from Tribler.Core.Modules.restapi.channels.channels_rss_endpoint import ChannelsRssFeedsEndpoint, \ - ChannelsRecheckFeedsEndpoint -from Tribler.Core.Modules.restapi.channels.channels_torrents_endpoint import ChannelsTorrentsEndpoint - - -class ChannelsDiscoveredEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for requests regarding the discovered channels. - """ - - def getChild(self, path, request): - return ChannelsDiscoveredSpecificEndpoint(self.session, path) - - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered - - Create your own new channel. The passed mode and descriptions are optional. - Valid modes include: 'open', 'semi-open' or 'closed'. By default, the mode of the new channel is 'closed'. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered - --data "name=fancy name&description=fancy description&mode=open" - - **Example response**: - - .. sourcecode:: javascript - - { - "added": 23 - } - - :statuscode 500: if a channel with the specified name already exists. - """ - parameters = http.parse_qs(request.content.read(), 1) - - if 'name' not in parameters or len(parameters['name']) == 0 or len(parameters['name'][0]) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "channel name cannot be empty"}) - - if 'description' not in parameters or len(parameters['description']) == 0: - description = u'' - else: - description = str(parameters['description'][0]).encode('utf-8') - - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - - # Do not allow to add a channel twice - if self.session.lm.mds.get_my_channel(): - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "channel already exists"}) - - title = str(parameters['name'][0]).encode('utf-8') - self.session.lm.mds.ChannelMetadata.create_channel(title, description) - return json.dumps({ - "added": str(my_channel_id).encode("hex"), - }) - - -class ChannelsDiscoveredSpecificEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for dispatching requests to perform operations in a specific discovered channel. - """ - - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = bytes(cid.decode('hex')) - - child_handler_dict = {"torrents": ChannelsTorrentsEndpoint, "rssfeeds": ChannelsRssFeedsEndpoint, - "playlists": ChannelsPlaylistsEndpoint, "recheckfeeds": ChannelsRecheckFeedsEndpoint, - "mdblob": ChannelsDiscoveredExportEndpoint} - for path, child_cls in child_handler_dict.iteritems(): - self.putChild(path, child_cls(session, self.cid)) - - def render_GET(self, request): - """ - .. http:get:: /channels/discovered/(string: channelid) - - Return the name, description and identifier of a channel. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/discovered/4a9cfc7ca9d15617765f4151dd9fae94c8f3ba11 - - **Example response**: - - .. sourcecode:: javascript - - { - "overview": { - "name": "My Tribler channel", - "description": "A great collection of open-source movies", - "identifier": "4a9cfc7ca9d15617765f4151dd9fae94c8f3ba11" - } - } - - :statuscode 404: if your channel has not been created (yet). - """ - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsDiscoveredSpecificEndpoint.return_404(request) - - return json.dumps({'overview': {'identifier': channel_info[1].encode('hex'), 'name': channel_info[2], - 'description': channel_info[3]}}) - - -class ChannelsDiscoveredExportEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for serving .mdblob file export requests for a specific channel. - """ - - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - self.is_chant_channel = (len(cid) == 74) - - def render_GET(self, request): - """ - .. http:get:: /channels/discovered/(string: channelid)/mdblob - - Return the mdblob binary - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/discovered/(string: channel_id)/mdblob - - **Example response**: - - The .mdblob file containing the serialized and signed metadata for the channelid. - - :statuscode 404: if channel with given channeld is not found. - """ - with db_session: - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(self.cid) - if not channel: - return ChannelsDiscoveredSpecificEndpoint.return_404(request) - else: - mdblob = channel.serialized() - - request.setHeader(b'content-type', 'application/octet-stream') - request.setHeader(b'Content-Disposition', 'attachment; filename=%s.mdblob' % self.cid.encode('hex')) - return mdblob diff --git a/Tribler/Core/Modules/restapi/channels/channels_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_endpoint.py deleted file mode 100644 index 5de7e5939ac..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_endpoint.py +++ /dev/null @@ -1,18 +0,0 @@ -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.channels.channels_discovered_endpoint import ChannelsDiscoveredEndpoint -from Tribler.Core.Modules.restapi.channels.channels_popular_endpoint import ChannelsPopularEndpoint -from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import ChannelsSubscribedEndpoint - - -class ChannelsEndpoint(BaseChannelsEndpoint): - """ - This endpoint is responsible for handing all requests regarding channels in Tribler. - """ - - def __init__(self, session): - BaseChannelsEndpoint.__init__(self, session) - - child_handler_dict = {"subscribed": ChannelsSubscribedEndpoint, "discovered": ChannelsDiscoveredEndpoint, - "popular": ChannelsPopularEndpoint} - for path, child_cls in child_handler_dict.iteritems(): - self.putChild(path, child_cls(self.session)) diff --git a/Tribler/Core/Modules/restapi/channels/channels_playlists_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_playlists_endpoint.py deleted file mode 100644 index a915cc26337..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_playlists_endpoint.py +++ /dev/null @@ -1,359 +0,0 @@ -from twisted.web import http - -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.util import convert_db_torrent_to_json -import Tribler.Core.Utilities.json_util as json - - -class ChannelsPlaylistsEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for handling requests regarding playlists in a channel. - """ - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - - def getChild(self, path, request): - return ChannelsModifyPlaylistsEndpoint(self.session, self.cid, path) - - def render_GET(self, request): - """ - .. http:get:: /channels/discovered/(string: channelid)/playlists - - Returns the playlists in your channel. Returns error 404 if you have not created a channel. - - disable_filter: whether the family filter should be disabled for this request (1 = disabled) - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/discovered/abcd/playlists - - **Example response**: - - .. sourcecode:: javascript - - { - "playlists": [{ - "id": 1, - "name": "My first playlist", - "description": "Funny movies", - "torrents": [{ - "id": 4, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779", - "name": "Ubuntu-16.04-desktop-amd64", - "size": 8592385, - "category": "other", - "num_seeders": 42, - "num_leechers": 184, - "last_tracker_check": 1463176959 - }, ... ] - }, ...] - } - - :statuscode 404: if you have not created a channel. - """ - - channel = self.get_channel_from_db(self.cid) - if channel is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - playlists = [] - req_columns = ['Playlists.id', 'Playlists.name', 'Playlists.description'] - req_columns_torrents = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', - 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.inserted'] - - should_filter = self.session.config.get_family_filter_enabled() - if 'disable_filter' in request.args and len(request.args['disable_filter']) > 0 \ - and request.args['disable_filter'][0] == "1": - should_filter = False - - for playlist in self.channel_db_handler.getPlaylistsFromChannelId(channel[0], req_columns): - # Fetch torrents in the playlist - playlist_torrents = self.channel_db_handler.getTorrentsFromPlaylist(playlist[0], req_columns_torrents) - torrents = [] - for torrent_result in playlist_torrents: - torrent = convert_db_torrent_to_json(torrent_result) - if (should_filter and torrent['category'] == 'xxx') or torrent['name'] is None: - continue - torrents.append(torrent) - - playlists.append({"id": playlist[0], "name": playlist[1], "description": playlist[2], "torrents": torrents}) - - return json.dumps({"playlists": playlists}) - - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/playlists - - Create a new empty playlist with a given name and description. The name and description parameters are - mandatory. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcd/playlists - --data "name=My fancy playlist&description=This playlist contains some random movies" - - **Example response**: - - .. sourcecode:: javascript - - { - "created": True - } - - :statuscode 400: if you are missing the name and/or description parameter - :statuscode 404: if the specified channel does not exist - """ - parameters = http.parse_qs(request.content.read(), 1) - - if 'name' not in parameters or len(parameters['name']) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "name parameter missing"}) - - if 'description' not in parameters or len(parameters['description']) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "description parameter missing"}) - - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the specific channel cannot be found") - - channel_community.create_playlist(unicode(parameters['name'][0], 'utf-8'), - unicode(parameters['description'][0], 'utf-8'), []) - - return json.dumps({"created": True}) - - -class ChannelsModifyPlaylistsEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for requests that are modifying a specific playlist in a channel. - """ - - def __init__(self, session, cid, playlist_id): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - self.playlist_id = playlist_id - - def getChild(self, path, request): - return ChannelsModifyPlaylistTorrentsEndpoint(self.session, self.cid, self.playlist_id, path) - - def render_DELETE(self, request): - """ - .. http:delete:: /channels/discovered/(string: channelid)/playlists/(int: playlistid) - - Remove a playlist with a specified playlist id. - - **Example request**: - - .. sourcecode:: none - - curl -X DELETE http://localhost:8085/channels/discovered/abcd/playlists/3 - - **Example response**: - - .. sourcecode:: javascript - - { - "removed": True - } - - :statuscode 404: if the specified channel (community) or playlist does not exist - """ - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - playlist = self.channel_db_handler.getPlaylist(self.playlist_id, ['Playlists.dispersy_id', 'Playlists.id']) - if playlist is None: - return BaseChannelsEndpoint.return_404(request, message="this playlist cannot be found") - - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the specific channel cannot be found") - - # Remove all torrents from this playlist - playlist_torrents = self.channel_db_handler.get_torrent_ids_from_playlist(playlist[1]) - channel_community.remove_playlist_torrents(playlist[0], [dispersy_id for dispersy_id, in playlist_torrents]) - - # Remove the playlist itself - channel_community.remove_playlists([playlist[0]]) - - return json.dumps({"removed": True}) - - def render_POST(self, request): - """ - .. http:post:: /channels/discovered/(string: channelid)/playlists/(int: playlistid) - - Edit a specific playlist. The new name and description should be passed as parameter. - - **Example request**: - - .. sourcecode:: none - - curl -X POST http://localhost:8085/channels/discovered/abcd/playlists/3 - --data "name=test&description=my test description" - - **Example response**: - - .. sourcecode:: javascript - - { - "modified": True - } - - :statuscode 404: if the specified channel (community) or playlist does not exist or if the - name and description parameters are missing. - """ - parameters = http.parse_qs(request.content.read(), 1) - - if 'name' not in parameters or len(parameters['name']) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "name parameter missing"}) - - if 'description' not in parameters or len(parameters['description']) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "description parameter missing"}) - - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - playlist = self.channel_db_handler.getPlaylist(self.playlist_id, ['Playlists.id']) - if playlist is None: - return BaseChannelsEndpoint.return_404(request, message="this playlist cannot be found") - - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the specific channel cannot be found") - - channel_community.modifyPlaylist(playlist[0], {'name': parameters['name'][0], - 'description': parameters['description'][0]}) - - return json.dumps({"modified": True}) - - -class ChannelsModifyPlaylistTorrentsEndpoint(BaseChannelsEndpoint): - - def __init__(self, session, cid, playlist_id, infohash): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - self.playlist_id = playlist_id - self.infohash = infohash.decode('hex') - - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/playlists/(int: playlistid)/(string: infohash) - - Add a torrent with a specified infohash to a specified playlist. The torrent that is added to the playlist, - should be present in the channel. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcd/playlists/3/abcdef - - **Example response**: - - .. sourcecode:: javascript - - { - "added": True - } - - :statuscode 404: if the specified channel/playlist/torrent does not exist. - :statuscode 409: if the specified torrent is already in the specified playlist. - """ - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the specific channel cannot be found") - - playlist = self.channel_db_handler.getPlaylist(self.playlist_id, ['Playlists.dispersy_id']) - if playlist is None: - return BaseChannelsEndpoint.return_404(request, message="this playlist cannot be found") - - # Check whether this torrent is present in your channel - torrent_in_channel = False - for torrent in self.channel_db_handler.getTorrentsFromChannelId(channel_info[0], True, ["infohash"]): - if torrent[0] == self.infohash: - torrent_in_channel = True - break - - if not torrent_in_channel: - return BaseChannelsEndpoint.return_404(request, message="this torrent is not available in your channel") - - # Check whether this torrent is not already present in this playlist - for torrent in self.channel_db_handler.getTorrentsFromPlaylist(self.playlist_id, ["infohash"]): - if torrent[0] == self.infohash: - request.setResponseCode(http.CONFLICT) - return json.dumps({"error": "this torrent is already in your playlist"}) - - channel_community.create_playlist_torrents(int(self.playlist_id), [self.infohash]) - - return json.dumps({"added": True}) - - def render_DELETE(self, request): - """ - .. http:delete:: /channels/discovered/(string: channelid)/playlists/(int: playlistid)/(string: infohash) - - Remove a torrent with a specified infohash from a specified playlist. - - **Example request**: - - .. sourcecode:: none - - curl -X DELETE http://localhost:8085/channels/discovered/abcd/playlists/3/abcdef - - **Example response**: - - .. sourcecode:: javascript - - { - "removed": True - } - - :statuscode 404: if the specified channel/playlist/torrent does not exist. - """ - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsPlaylistsEndpoint.return_404(request) - - playlist = self.channel_db_handler.getPlaylist(self.playlist_id, ['Playlists.dispersy_id']) - if playlist is None: - return BaseChannelsEndpoint.return_404(request, message="this playlist cannot be found") - - channel_community = self.get_community_for_channel_id(channel_info[0]) - if channel_community is None: - return BaseChannelsEndpoint.return_404(request, - message="the community for the specific channel cannot be found") - - # Check whether this torrent is present in this playlist and if so, get the dispersy ID - torrent_dispersy_id = -1 - for torrent in self.channel_db_handler.getTorrentsFromPlaylist(self.playlist_id, - ["infohash", "PlaylistTorrents.dispersy_id"]): - if torrent[0] == self.infohash: - torrent_dispersy_id = torrent[1] - break - - if torrent_dispersy_id == -1: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "this torrent is not in your playlist"}) - - channel_community.remove_playlist_torrents(int(self.playlist_id), [torrent_dispersy_id]) - - return json.dumps({"removed": True}) diff --git a/Tribler/Core/Modules/restapi/channels/channels_popular_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_popular_endpoint.py deleted file mode 100644 index 828b0faf106..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_popular_endpoint.py +++ /dev/null @@ -1,61 +0,0 @@ -from twisted.web import http - -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.util import convert_db_channel_to_json -import Tribler.Core.Utilities.json_util as json - - -class ChannelsPopularEndpoint(BaseChannelsEndpoint): - - def render_GET(self, request): - """ - .. http:get:: /channels/popular?limit=(int:max nr of channels) - - A GET request to this endpoint will return the most popular discovered channels in Tribler. - You can optionally pass a limit parameter to limit the number of results. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/popular?limit=1 - - **Example response**: - - .. sourcecode:: javascript - - { - "channels": [{ - "id": 3, - "dispersy_cid": "da69aaad39ccf468aba2ab9177d5f8d8160135e6", - "name": "My fancy channel", - "description": "A description of this fancy channel", - "subscribed": False, - "votes": 23, - "torrents": 3, - "spam": 5, - "modified": 14598395, - "can_edit": True, - }] - } - """ - limit_channels = 10 - - if 'limit' in request.args and len(request.args['limit']) > 0: - limit_channels = int(request.args['limit'][0]) - - if limit_channels <= 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "the limit parameter must be a positive number"}) - - popular_channels = self.channel_db_handler.getMostPopularChannels(max_nr=limit_channels) - results_json = [] - for channel in popular_channels: - channel_json = convert_db_channel_to_json(channel) - if self.session.config.get_family_filter_enabled() and \ - self.session.lm.category.xxx_filter.isXXX(channel_json['name']): - continue - - results_json.append(channel_json) - - return json.dumps({"channels": results_json}) diff --git a/Tribler/Core/Modules/restapi/channels/channels_rss_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_rss_endpoint.py deleted file mode 100644 index c65c06e19d7..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_rss_endpoint.py +++ /dev/null @@ -1,190 +0,0 @@ -from twisted.web import http -from twisted.web.server import NOT_DONE_YET - -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -import Tribler.Core.Utilities.json_util as json - - -class BaseChannelsRssFeedsEndpoint(BaseChannelsEndpoint): - - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - - def get_my_channel_obj_or_error(self, request): - """ - Returns a tuple of (channel_obj, error). Callers of this method should check whether the channel_obj is None and - if so, return the error. - """ - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return None, BaseChannelsRssFeedsEndpoint.return_404(request) - - if channel_info[0] != self.channel_db_handler.getMyChannelId(): - return None, BaseChannelsRssFeedsEndpoint.return_401(request) - - channel_obj = self.get_my_channel_object() - if channel_obj is None: - return None, BaseChannelsRssFeedsEndpoint.return_404(request) - - return channel_obj, None - - -class ChannelsRssFeedsEndpoint(BaseChannelsRssFeedsEndpoint): - """ - This class is responsible for handling requests regarding rss feeds in a channel. - """ - - def getChild(self, path, request): - return ChannelModifyRssFeedEndpoint(self.session, self.cid, path) - - def render_GET(self, request): - """ - .. http:get:: /channels/discovered/(string: channelid)/rssfeeds - - Returns the RSS feeds in your channel. - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/discovered/abcd/rssfeeds - - **Example response**: - - .. sourcecode:: javascript - - { - "rssfeeds": [{ - "url": "http://rssprovider.com/feed.xml", - }, ...] - } - """ - channel_obj, error = self.get_my_channel_obj_or_error(request) - if channel_obj is None: - return error - - request.setHeader('Content-Type', 'text/json') - feeds_list = [{'url': rss_item} for rss_item in channel_obj.get_rss_feed_url_list()] - - return json.dumps({"rssfeeds": feeds_list}) - - -class ChannelsRecheckFeedsEndpoint(BaseChannelsRssFeedsEndpoint): - """ - This class is responsible for handling requests regarding refreshing rss feeds in your channel. - """ - - def render_POST(self, request): - """ - .. http:post:: /channels/discovered/(string: channelid)/recheckfeeds - - Rechecks all rss feeds in your channel. Returns error 404 if you channel does not exist. - - **Example request**: - - .. sourcecode:: none - - curl -X POST http://localhost:8085/channels/discovered/recheckrssfeeds - - **Example response**: - - .. sourcecode:: javascript - - { - "rechecked": True - } - - :statuscode 404: if you have not created a channel. - """ - channel_obj, error = self.get_my_channel_obj_or_error(request) - if channel_obj is None: - return error - - def on_refreshed(_): - request.write(json.dumps({"rechecked": True})) - request.finish() - - def on_refresh_error(failure): - self._logger.exception(failure.value) - request.write(BaseChannelsEndpoint.return_500(self, request, failure.value)) - request.finish() - - channel_obj.refresh_all_feeds().addCallbacks(on_refreshed, on_refresh_error) - - return NOT_DONE_YET - - -class ChannelModifyRssFeedEndpoint(BaseChannelsRssFeedsEndpoint): - """ - This class is responsible for methods that modify the list of RSS feed URLs (adding/removing feeds). - """ - - def __init__(self, session, cid, feed_url): - BaseChannelsRssFeedsEndpoint.__init__(self, session, cid) - self.feed_url = feed_url - - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/rssfeeds/http%3A%2F%2Ftest.com%2Frss.xml - - Add a RSS feed to your channel. Returns error 409 if the supplied RSS feed already exists. - Note that the rss feed url should be URL-encoded. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcd/rssfeeds/http%3A%2F%2Ftest.com%2Frss.xml - - **Example response**: - - .. sourcecode:: javascript - - { - "added": True - } - - :statuscode 409: (conflict) if the specified RSS URL is already present in your feeds. - """ - channel_obj, error = self.get_my_channel_obj_or_error(request) - if channel_obj is None: - return error - - if self.feed_url in channel_obj.get_rss_feed_url_list(): - request.setResponseCode(http.CONFLICT) - return json.dumps({"error": "this rss feed already exists"}) - - channel_obj.create_rss_feed(self.feed_url) - return json.dumps({"added": True}) - - def render_DELETE(self, request): - """ - .. http:delete:: /channels/discovered/(string: channelid)/rssfeeds/http%3A%2F%2Ftest.com%2Frss.xml - - Delete a RSS feed from your channel. Returns error 404 if the RSS feed that is being removed does not exist. - Note that the rss feed url should be URL-encoded. - - **Example request**: - - .. sourcecode:: none - - curl -X DELETE http://localhost:8085/channels/discovered/abcd/rssfeeds/http%3A%2F%2Ftest.com%2Frss.xml - - **Example response**: - - .. sourcecode:: javascript - - { - "removed": True - } - - :statuscode 404: if the specified RSS URL is not in your feed list. - """ - channel_obj, error = self.get_my_channel_obj_or_error(request) - if channel_obj is None: - return error - - if self.feed_url not in channel_obj.get_rss_feed_url_list(): - return ChannelModifyRssFeedEndpoint.return_404(request, message="this url is not added to your RSS feeds") - - channel_obj.remove_rss_feed(self.feed_url) - return json.dumps({"removed": True}) diff --git a/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py deleted file mode 100644 index 72b20a0c630..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_subscription_endpoint.py +++ /dev/null @@ -1,190 +0,0 @@ -from __future__ import absolute_import - -from pony.orm import db_session -from twisted.web import http -from twisted.web.server import NOT_DONE_YET - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE, VOTE_UNSUBSCRIBE -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.util import convert_db_channel_to_json, convert_chant_channel_to_json -from Tribler.pyipv8.ipv8.database import database_blob - -ALREADY_SUBSCRIBED_RESPONSE_MSG = "you are already subscribed to this channel" -NOT_SUBSCRIBED_RESPONSE_MSG = "you are not subscribed to this channel" -CHANNEL_NOT_FOUND = "this channel is not found" - - -class ChannelsSubscribedEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for requests regarding the subscriptions to channels. - """ - def getChild(self, path, request): - return ChannelsModifySubscriptionEndpoint(self.session, path) - - def render_GET(self, _): - """ - .. http:get:: /channels/subscribed - - Returns all the channels the user is subscribed to. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/subscribed - - **Example response**: - - .. sourcecode:: javascript - - { - "subscribed": [{ - "id": 3, - "dispersy_cid": "da69aaad39ccf468aba2ab9177d5f8d8160135e6", - "name": "My fancy channel", - "description": "A description of this fancy channel", - "subscribed": True, - "votes": 23, - "torrents": 3, - "spam": 5, - "modified": 14598395, - "can_edit": True, - }, ...] - } - """ - subscribed_channels_db = self.channel_db_handler.getMySubscribedChannels(include_dispersy=True) - results_json = [convert_db_channel_to_json(channel) for channel in subscribed_channels_db] - if self.session.config.get_chant_enabled(): - with db_session: - channels_list = list(self.session.lm.mds.ChannelMetadata.select(lambda g: g.subscribed)) - results_json.extend([convert_chant_channel_to_json(channel) for channel in channels_list]) - return json.dumps({"subscribed": results_json}) - - -class ChannelsModifySubscriptionEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for methods that modify the list of RSS feed URLs (adding/removing feeds). - """ - - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = bytes(cid.decode('hex')) - - def render_GET(self, request): - """ - .. http:get:: /channels/subscribed/(string: channelid) - - Shows the status of subscription to a specific channel along with number of existing votes in the channel - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/channels/subscribed/da69aaad39ccf468aba2ab9177d5f8d8160135e6 - - **Example response**: - - .. sourcecode:: javascript - - { - "subscribed" : True, "votes": 111 - } - """ - request.setHeader('Content-Type', 'text/json') - channel_info = self.get_channel_from_db(self.cid) - - if channel_info is None: - return ChannelsModifySubscriptionEndpoint.return_404(request) - - response = dict() - response[u'subscribed'] = channel_info[7] == VOTE_SUBSCRIBE - response[u'votes'] = channel_info[5] - - return json.dumps(response) - - def render_PUT(self, request): - """ - .. http:put:: /channels/subscribed/(string: channelid) - - Subscribe to a specific channel. Returns error 409 if you are already subscribed to this channel. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/subscribed/da69aaad39ccf468aba2ab9177d5f8d8160135e6 - - **Example response**: - - .. sourcecode:: javascript - - { - "subscribed" : True - } - - :statuscode 409: (conflict) if you are already subscribed to the specified channel. - """ - request.setHeader('Content-Type', 'text/json') - - with db_session: - channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.cid)) - if not channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": CHANNEL_NOT_FOUND}) - - if channel.subscribed: - request.setResponseCode(http.CONFLICT) - return json.dumps({"error": ALREADY_SUBSCRIBED_RESPONSE_MSG}) - channel.subscribed = True - - return json.dumps({"subscribed": True}) - - def render_DELETE(self, request): - """ - .. http:delete:: /channels/subscribed/(string: channelid) - - Unsubscribe from a specific channel. Returns error 404 if you are not subscribed to this channel. - - **Example request**: - - .. sourcecode:: none - - curl -X DELETE http://localhost:8085/channels/subscribed/da69aaad39ccf468aba2ab9177d5f8d8160135e6 - - **Example response**: - - .. sourcecode:: javascript - - { - "unsubscribed" : True - } - - :statuscode 404: if you are not subscribed to the specified channel. - """ - request.setHeader('Content-Type', 'text/json') - - if len(self.cid) == 74: - with db_session: - channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.cid)) - if not channel: - return ChannelsModifySubscriptionEndpoint.return_404(request) - elif not channel.subscribed: - return ChannelsModifySubscriptionEndpoint.return_404(request, message=NOT_SUBSCRIBED_RESPONSE_MSG) - self.session.lm.gigachannel_manager.remove_channel(channel) - return json.dumps({"unsubscribed": True}) - - channel_info = self.get_channel_from_db(self.cid) - if channel_info is None: - return ChannelsModifySubscriptionEndpoint.return_404(request) - - if channel_info[7] != VOTE_SUBSCRIBE: - return ChannelsModifySubscriptionEndpoint.return_404(request, message=NOT_SUBSCRIBED_RESPONSE_MSG) - - def on_vote_done(_): - request.write(json.dumps({"unsubscribed": True})) - request.finish() - - self.vote_for_channel(self.cid, VOTE_UNSUBSCRIBE).addCallback(on_vote_done) - - return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py b/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py deleted file mode 100644 index 7f1520e98c9..00000000000 --- a/Tribler/Core/Modules/restapi/channels/channels_torrents_endpoint.py +++ /dev/null @@ -1,360 +0,0 @@ -from __future__ import absolute_import - -import base64 -import os -import sys -from binascii import unhexlify - -from pony.orm import db_session, desc -from twisted.internet.defer import Deferred -from twisted.web import http -from twisted.web.error import SchemeNotSupported -from twisted.web.server import NOT_DONE_YET - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.util import convert_db_torrent_to_json, convert_torrent_metadata_to_tuple -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.utilities import http_get -from Tribler.Core.exceptions import DuplicateTorrentFileError, HttpError - -UNKNOWN_TORRENT_MSG = "this torrent is not found in the specified channel" -UNKNOWN_COMMUNITY_MSG = "the community for the specified channel cannot be found" - - -def chunks(l, n): - """Yield successive n-sized chunks from l.""" - for i in range(0, len(l), n): - yield l[i:i + n] - - -class ChannelsTorrentsEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for managing requests regarding torrents in a channel. - """ - - def __init__(self, session, cid): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - self.is_chant_channel = (len(cid) == 74) - - def getChild(self, path, request): - return ChannelModifyTorrentEndpoint(self.session, self.cid, path) - - @db_session - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/torrents - - Add a torrent file to your own channel. Returns error 500 if something is wrong with the torrent file - and DuplicateTorrentFileError if already added to your channel. The torrent data is passed as base-64 encoded - string. The description is optional. - - Option torrents_dir adds all .torrent files from a chosen directory - Option recursive enables recursive scanning of the chosen directory for .torrent files - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcd/torrents - --data "torrent=...&description=funny video" - - **Example response**: - - .. sourcecode:: javascript - - { - "added": True - } - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcd/torrents?torrents_dir=some_dir&recursive=1 - --data "" - - **Example response**: - - .. sourcecode:: javascript - - { - "added": True - "num_added_torrents": 13 - } - - :statuscode 404: if your channel does not exist. - :statuscode 500: if the passed torrent data is corrupt. - """ - key = self.session.trustchain_keypair - my_channel_id = key.pub().key_to_bin() - - # First check whether the channel actually exists - if self.is_chant_channel: - if my_channel_id != self.cid: - request.setResponseCode(http.NOT_ALLOWED) - return json.dumps({"error": "you can only add torrents to your own chant channel"}) - - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - if not channel: - return ChannelsTorrentsEndpoint.return_404(request) - else: - channel = self.get_channel_from_db(self.cid) - if channel is None: - return ChannelsTorrentsEndpoint.return_404(request) - - parameters = http.parse_qs(request.content.read(), 1) - - torrents_dir = None - if 'torrents_dir' in parameters and parameters['torrents_dir'] > 0: - torrents_dir = parameters['torrents_dir'][0] - if not os.path.isabs(torrents_dir): - request.setResponseCode(http.BAD_REQUEST) - - recursive = False - if 'recursive' in parameters and parameters['recursive'] > 0: - recursive = parameters['recursive'][0] - if not torrents_dir: - request.setResponseCode(http.BAD_REQUEST) - - if torrents_dir: - torrents_list = [] - errors_list = [] - filename_generator = None - - if recursive: - def rec_gen(): - for root, _, filenames in os.walk(torrents_dir): - for fn in filenames: - yield os.path.join(root, fn) - - filename_generator = rec_gen() - else: - filename_generator = os.listdir(torrents_dir) - - # Build list of .torrents to process - for f in filename_generator: - filepath = os.path.join(torrents_dir, f) - filename = str(filepath) if sys.platform == 'win32' else filepath.decode('utf-8') - if os.path.isfile(filepath) and filename.endswith(u'.torrent'): - torrents_list.append(filepath) - - for chunk in chunks(torrents_list, 100): # 100 is a reasonable chunk size for commits - with db_session: - for f in chunk: - try: - channel.add_torrent_to_channel(TorrentDef.load(f), {}) - except DuplicateTorrentFileError: - pass - except: - errors_list.append(f) - - return json.dumps({"added": len(torrents_list), "errors": errors_list}) - - if 'torrent' not in parameters or len(parameters['torrent']) == 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "torrent parameter missing"}) - - if 'description' not in parameters or len(parameters['description']) == 0: - extra_info = {} - else: - extra_info = {'description': parameters['description'][0]} - - # Try to parse the torrent data - try: - torrent = base64.b64decode(parameters['torrent'][0]) - torrent_def = TorrentDef.load_from_memory(torrent) - except ValueError as exc: - return BaseChannelsEndpoint.return_500(self, request, exc) - - if self.is_chant_channel: - try: - channel.add_torrent_to_channel(torrent_def, extra_info) - except DuplicateTorrentFileError as exc: - return BaseChannelsEndpoint.return_500(self, request, exc) - else: - try: - self.session.add_torrent_def_to_channel(channel[0], torrent_def, extra_info, forward=True) - except (DuplicateTorrentFileError, HttpError) as ex: - return BaseChannelsEndpoint.return_500(self, request, ex) - - return json.dumps({"added": True}) - - -class ChannelModifyTorrentEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for methods that modify the list of torrents (adding/removing torrents). - """ - - def __init__(self, session, cid, path): - BaseChannelsEndpoint.__init__(self, session) - self.cid = cid - self.path = path - self.deferred = Deferred() - self.is_chant_channel = (len(cid) == 74) - - @db_session - def render_PUT(self, request): - """ - .. http:put:: /channels/discovered/(string: channelid)/torrents/http%3A%2F%2Ftest.com%2Ftest.torrent - - Add a torrent by magnet or url to your channel. Returns error 500 if something is wrong with the torrent file - and DuplicateTorrentFileError if already added to your channel (except with magnet links). - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/channels/discovered/abcdefg/torrents/ - http%3A%2F%2Ftest.com%2Ftest.torrent --data "description=nice video" - - **Example response**: - - .. sourcecode:: javascript - - { - "added": "http://test.com/test.torrent" - } - - :statuscode 404: if your channel does not exist. - :statuscode 500: if the specified torrent is already in your channel. - """ - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - - if self.is_chant_channel: - if my_channel_id != self.cid: - request.setResponseCode(http.NOT_ALLOWED) - return json.dumps({"error": "you can only add torrents to your own channel"}) - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - else: - channel = self.get_channel_from_db(self.cid) - - if channel is None: - return BaseChannelsEndpoint.return_404(request) - - parameters = http.parse_qs(request.content.read(), 1) - - if 'description' not in parameters or len(parameters['description']) == 0: - extra_info = {} - else: - extra_info = {'description': parameters['description'][0]} - - def _on_url_fetched(data): - return TorrentDef.load_from_memory(data) - - def _on_magnet_fetched(meta_info): - return TorrentDef.load_from_dict(meta_info) - - def _on_torrent_def_loaded(torrent_def): - if self.is_chant_channel: - # We have to get my channel again since we are in a different database session now - with db_session: - channel = self.session.lm.mds.get_my_channel() - channel.add_torrent_to_channel(torrent_def, extra_info) - else: - channel = self.get_channel_from_db(self.cid) - self.session.add_torrent_def_to_channel(channel[0], torrent_def, extra_info, forward=True) - return self.path - - def _on_added(added): - request.write(json.dumps({"added": added})) - request.finish() - - def _on_add_failed(failure): - failure.trap(ValueError, DuplicateTorrentFileError, SchemeNotSupported) - self._logger.exception(failure.value) - request.write(BaseChannelsEndpoint.return_500(self, request, failure.value)) - request.finish() - - def _on_timeout(_): - request.write(BaseChannelsEndpoint.return_500(self, request, RuntimeError("Metainfo timeout"))) - request.finish() - - if self.path.startswith("http:") or self.path.startswith("https:"): - self.deferred = http_get(self.path) - self.deferred.addCallback(_on_url_fetched) - - if self.path.startswith("magnet:"): - try: - self.session.lm.ltmgr.get_metainfo(self.path, callback=self.deferred.callback, - timeout=30, timeout_callback=_on_timeout, notify=True) - except Exception as ex: - self.deferred.errback(ex) - - self.deferred.addCallback(_on_magnet_fetched) - - self.deferred.addCallback(_on_torrent_def_loaded) - self.deferred.addCallback(_on_added) - self.deferred.addErrback(_on_add_failed) - return NOT_DONE_YET - - def render_DELETE(self, request): - """ - .. http:delete:: /channels/discovered/(string: channelid)/torrents/(string: comma separated torrent infohashes - or * for all torrents in the channel) - - - Remove a single or multiple torrents with the given comma separated infohashes from a given channel. - restore option will revert the selected torrent from 'TODELETE' state to 'COMMITTED' state - - **Example request**: - - .. sourcecode:: none - - curl -X DELETE http://localhost:8085/channels/discovered/abcdefg/torrents/ - 97d2d8f5d37e56cfaeaae151d55f05b077074779,971d55f05b077074779d2d8f5d37e56cfaeaae15 - - curl -X DELETE http://localhost:8085/channels/discovered/abcdefg/torrents/ - 97d2d8f5d37e56cfaeaae151d55f05b077074779,971d55f05b077074779d2d8f5d37e56cfaeaae15?restore=1 - - **Example response**: - - .. sourcecode:: javascript - - { - "removed": True - } - - .. sourcecode:: javascript - - { - "removed": False, "failed_torrents":["97d2d8f5d37e56cfaeaae151d55f05b077074779"] - } - - :statuscode 404: if the channel is not found - """ - restore = 'restore' in request.args and request.args['restore'][0] == "1" - - with db_session: - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - failed_torrents = [] - - if my_channel_id != self.cid: - request.setResponseCode(http.NOT_ALLOWED) - return json.dumps({"error": "you can only remove torrents from your own chant channel"}) - - my_channel = self.session.lm.mds.get_my_channel() - if not my_channel: - return ChannelsTorrentsEndpoint.return_404(request) - - if self.path == u'*': - if restore: - return json.dumps({"error": "trying to mass restore channel contents: not implemented"}) - - my_channel.drop_channel_contents() - return json.dumps({"removed": True}) - - for torrent_path in self.path.split(","): - infohash = unhexlify(torrent_path) - if restore: - if not my_channel.cancel_torrent_deletion(infohash): - failed_torrents.append(torrent_path) - elif not my_channel.delete_torrent_from_channel(infohash): - failed_torrents.append(torrent_path) - - if failed_torrents: - return json.dumps({"removed": False, "failed_torrents": failed_torrents}) - return json.dumps({"removed": True}) diff --git a/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py b/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py deleted file mode 100644 index d11ec7c47ab..00000000000 --- a/Tribler/Core/Modules/restapi/channels/my_channel_endpoint.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import absolute_import -import os -from binascii import hexlify - -from pony.orm import db_session -from twisted.web import http - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import BaseChannelsEndpoint -from Tribler.Core.Modules.restapi.util import get_parameter - -NO_CHANNEL_CREATED_RESPONSE_MSG = "your channel has not been created" - - -class MyChannelEndpoint(BaseChannelsEndpoint): - """ - This class is responsible for managing requests regarding your channel. - """ - - def render_GET(self, request): - """ - .. http:get:: /mychannel - - Return the name, description and identifier of your channel. - This endpoint returns a 404 HTTP response if you have not created a channel (yet). - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/mychannel - - **Example response**: - - .. sourcecode:: javascript - - { - "overview": { - "name": "My Tribler channel", - "description": "A great collection of open-source movies", - "identifier": "4a9cfc7ca9d15617765f4151dd9fae94c8f3ba11" - } - } - - :statuscode 404: if your channel has not been created (yet). - """ - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - with db_session: - my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - - if not my_channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - - my_channel = my_channel.to_dict() - return json.dumps({ - 'mychannel': { - 'identifier': hexlify(str(my_channel["public_key"])), - 'name': my_channel["title"], - 'description': my_channel["tags"]}}) - - def render_POST(self, request): - """ - .. http:post:: /mychannel - - Modify the name and/or the description of your channel. - This endpoint returns a 404 HTTP response if you have not created a channel (yet). - - **Example request**: - - .. sourcecode:: none - - curl -X POST http://localhost:8085/mychannel - --data "name=My fancy playlist&description=This playlist contains some random movies" - - **Example response**: - - .. sourcecode:: javascript - - { - "modified": True - } - - :statuscode 404: if your channel has not been created (yet). - """ - parameters = http.parse_qs(request.content.read(), 1) - - if not get_parameter(parameters, 'name') and not get_parameter(parameters, 'commit_changes'): - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": 'channel name cannot be empty'}) - - with db_session: - modified = False - my_key = self.session.trustchain_keypair - my_channel_id = my_key.pub().key_to_bin() - my_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - - if not my_channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": NO_CHANNEL_CREATED_RESPONSE_MSG}) - - if get_parameter(parameters, 'name'): - my_channel.update_metadata(update_dict={ - "tags": str(get_parameter(parameters, 'description')).encode('utf-8'), - "title": str(get_parameter(parameters, 'name')).encode('utf-8') - }) - modified = True - - if get_parameter(parameters, 'commit_changes') and my_channel.staged_entries_list: - # Update torrent if we have uncommitted content in the channel - my_channel.commit_channel_torrent() - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) - modified = True - - return json.dumps({'modified': modified}) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py new file mode 100644 index 00000000000..b6b3e3d3301 --- /dev/null +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -0,0 +1,355 @@ +import json +import logging +from binascii import unhexlify + +from pony.orm import db_session +from twisted.web import resource, http +from twisted.web.server import NOT_DONE_YET + +from Tribler.pyipv8.ipv8.database import database_blob + + +class BaseMetadataEndpoint(resource.Resource): + + @staticmethod + def sanitize_parameters(parameters): + """ + Sanitize the parameters for a request that fetches channels. + """ + first = 1 if 'first' not in parameters else int(parameters['first'][0]) + last = 50 if 'last' not in parameters else int(parameters['last'][0]) + sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] + sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) + query_filter = None if 'filter' not in parameters else parameters['filter'][0] + + if sort_by: + sort_by = MetadataEndpoint.convert_sort_param_to_pony_col(sort_by) + + return first, last, sort_by, sort_asc, query_filter + + +class MetadataEndpoint(BaseMetadataEndpoint): + + def __init__(self, session): + BaseMetadataEndpoint.__init__(self) + + child_handler_dict = { + "channels": ChannelsEndpoint, + "torrents": TorrentsEndpoint + } + + for path, child_cls in child_handler_dict.items(): + self.putChild(path, child_cls(session)) + + @staticmethod + def convert_sort_param_to_pony_col(sort_param): + """ + Convert an incoming sort parameter to a pony column in the database. + :return a string with the right column. None if there exists no value for the given key. + """ + json2pony_columns = { + u'category': "tags", + u'id': "rowid", + u'name': "title", + u'size': "size", + u'infohash': "infohash", + u'date': "torrent_date", + u'status': 'status' + } + + if sort_param not in json2pony_columns: + return None + return json2pony_columns[sort_param] + + +class BaseChannelsEndpoint(resource.Resource): + + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + + @staticmethod + def sanitize_parameters(parameters): + """ + Sanitize the parameters for a request that fetches channels. + """ + first, last, sort_by, sort_asc, query_filter = BaseMetadataEndpoint.sanitize_parameters(parameters) + + subscribed = False + if 'subscribed' in parameters: + subscribed = bool(int(parameters['subscribed'][0])) + + return first, last, sort_by, sort_asc, query_filter, subscribed + + +class ChannelsEndpoint(BaseChannelsEndpoint): + + def getChild(self, path, request): + if path == "popular": + return ChannelsPopularEndpoint(self.session) + + return SpecificChannelEndpoint(self.session, path) + + def render_GET(self, request): + first, last, sort_by, sort_asc, query_filter, subscribed = ChannelsEndpoint.sanitize_parameters(request.args) + channels, total = self.session.lm.mds.ChannelMetadata.get_channels( + first, last, sort_by, sort_asc, query_filter, subscribed) + + channels = [channel.to_simple_dict() for channel in channels] + + return json.dumps({ + "channels": channels, + "first": first, + "last": last, + "sort_by": sort_by, + "sort_asc": int(sort_asc), + "total": total + }) + + +class ChannelsPopularEndpoint(BaseChannelsEndpoint): + + def render_GET(self, request): + limit_channels = 10 + + if 'limit' in request.args and request.args['limit']: + limit_channels = int(request.args['limit'][0]) + + if limit_channels <= 0: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "the limit parameter must be a positive number"}) + + popular_channels = self.session.lm.mds.ChannelMetadata.get_random_channels(limit=limit_channels) + return json.dumps({"channels": [channel.to_simple_dict() for channel in popular_channels]}) + + +class SpecificChannelEndpoint(BaseChannelsEndpoint): + + def __init__(self, session, channel_pk): + BaseChannelsEndpoint.__init__(self, session) + self.channel_pk = unhexlify(channel_pk) + + self.putChild("torrents", SpecificChannelTorrentsEndpoint(session, self.channel_pk)) + + def render_POST(self, request): + parameters = http.parse_qs(request.content.read(), 1) + if 'subscribe' not in parameters: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"success": False, "error": "subscribe parameter missing"}) + + to_subscribe = bool(int(parameters['subscribe'][0])) + with db_session: + channel = self.session.lm.mds.ChannelMetadata.get(public_key=database_blob(self.channel_pk)) + if not channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "this channel cannot be found"}) + + channel.subscribed = to_subscribe + + return json.dumps({"success": True, "subscribed": to_subscribe}) + + +class BaseTorrentsEndpoint(resource.Resource): + + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + + @staticmethod + def sanitize_parameters(parameters): + """ + Sanitize the parameters for a request that fetches channels. + """ + first, last, sort_by, sort_asc, query_filter = BaseMetadataEndpoint.sanitize_parameters(parameters) + + channel = '' + if 'channel' in parameters: + channel = unhexlify(parameters['channel'][0]) + + return first, last, sort_by, sort_asc, query_filter, channel + + +class SpecificChannelTorrentsEndpoint(BaseTorrentsEndpoint): + + def __init__(self, session, channel_pk): + BaseTorrentsEndpoint.__init__(self, session) + self.channel_pk = channel_pk + + @db_session + def render_GET(self, request): + first, last, sort_by, sort_asc, query_filter, _ = \ + SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) + torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( + first, last, sort_by, sort_asc, query_filter, self.channel_pk) + + torrents = [torrent.to_simple_dict() for torrent in torrents] + + return json.dumps({ + "torrents": torrents, + "first": first, + "last": last, + "sort_by": sort_by, + "sort_asc": int(sort_asc), + "total": total + }) + + +class TorrentsEndpoint(BaseTorrentsEndpoint): + + def __init__(self, session): + BaseTorrentsEndpoint.__init__(self, session) + self.putChild("random", TorrentsRandomEndpoint(session)) + + def getChild(self, path, request): + return SpecificTorrentEndpoint(self.session, path) + + +class SpecificTorrentEndpoint(resource.Resource): + """ + This class handles requests for a specific torrent. + """ + + def __init__(self, session, infohash): + resource.Resource.__init__(self) + self.session = session + self.infohash = infohash + + self.putChild("health", TorrentHealthEndpoint(self.session, self.infohash)) + + +class TorrentsRandomEndpoint(BaseTorrentsEndpoint): + + @db_session + def render_GET(self, request): + limit_torrents = 10 + + if 'limit' in request.args and request.args['limit']: + limit_torrents = int(request.args['limit'][0]) + + if limit_torrents <= 0: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "the limit parameter must be a positive number"}) + + random_torrents = self.session.lm.mds.TorrentMetadata.get_random_torrents(limit=limit_torrents) + return json.dumps({"torrents": [torrent.to_simple_dict() for torrent in random_torrents]}) + + +class TorrentHealthEndpoint(resource.Resource): + """ + This class is responsible for endpoints regarding the health of a torrent. + """ + + def __init__(self, session, infohash): + resource.Resource.__init__(self) + self.session = session + self.infohash = infohash + self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) + self._logger = logging.getLogger(self.__class__.__name__) + + def finish_request(self, request): + try: + request.finish() + except RuntimeError: + self._logger.warning("Writing response failed, probably the client closed the connection already.") + + def render_GET(self, request): + """ + .. http:get:: /torrents/(string: torrent infohash)/health + + Fetch the swarm health of a specific torrent. You can optionally specify the timeout to be used in the + connections to the trackers. This is by default 20 seconds. + By default, we will not check the health of a torrent again if it was recently checked. You can force a health + recheck by passing the refresh parameter. + + **Example request**: + + .. sourcecode:: none + + curl http://localhost:8085/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779/health?timeout=15&refresh=1 + + **Example response**: + + .. sourcecode:: javascript + + { + "http://mytracker.com:80/announce": [{ + "seeders": 43, + "leechers": 20, + "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779" + }], + "http://nonexistingtracker.com:80/announce": { + "error": "timeout" + } + } + + :statuscode 404: if the torrent is not found in the database + """ + timeout = 20 + if 'timeout' in request.args: + timeout = int(request.args['timeout'][0]) + + refresh = False + if 'refresh' in request.args and request.args['refresh'] and request.args['refresh'][0] == "1": + refresh = True + + torrent_db_columns = ['C.torrent_id', 'num_seeders', 'num_leechers', 'next_tracker_check'] + torrent_info = self.torrent_db.getTorrent(self.infohash.decode('hex'), torrent_db_columns) + + def on_health_result(result): + request.write(json.dumps({'health': result})) + self.finish_request(request) + + def on_magnet_timeout_error(_): + if not request.finished: + request.setResponseCode(http.NOT_FOUND) + request.write(json.dumps({"error": "torrent not found in database"})) + if not request.finished: + self.finish_request(request) + + def on_request_error(failure): + if not request.finished: + request.setResponseCode(http.BAD_REQUEST) + request.write(json.dumps({"error": failure.getErrorMessage()})) + # If the above request.write failed, the request will have already been finished + if not request.finished: + self.finish_request(request) + + def make_torrent_health_request(): + self.session.check_torrent_health(self.infohash.decode('hex'), timeout=timeout, scrape_now=refresh) \ + .addCallback(on_health_result).addErrback(on_request_error) + + magnet = None + if torrent_info is None: + # Maybe this is a chant torrent? + infohash = self.infohash.decode('hex') + with db_session: + md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: + g.infohash == database_blob(infohash))) + if md_list: + torrent_md = md_list[0] # Any MD containing this infohash is fine + magnet = torrent_md.get_magnet() + if 'timeout' in request.args: + timeout = int(request.args['timeout'][0]) + else: + timeout = 50 + + def _add_torrent_and_check(metainfo): + tdef = TorrentDef.load_from_dict(metainfo) + assert (tdef.infohash == infohash), "DHT infohash does not match locally generated one" + self._logger.info("Chant-managed torrent fetched from DHT. Adding it to local cache, %s", self.infohash) + self.session.lm.torrent_db.addExternalTorrent(tdef) + self.session.lm.torrent_db._db.commit_now() + make_torrent_health_request() + + if magnet: + # Try to get the torrent from DHT and add it to the local cache + self._logger.info("Chant-managed torrent not in cache. Going to fetch it from DHT, %s", self.infohash) + self.session.lm.ltmgr.get_metainfo(magnet, callback=_add_torrent_and_check, + timeout=timeout, timeout_callback=on_magnet_timeout_error, notify=False) + elif torrent_info is None: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "torrent not found in database"}) + else: + make_torrent_health_request() + + return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py new file mode 100644 index 00000000000..8af5157a7cc --- /dev/null +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -0,0 +1,197 @@ +import json +import os +from binascii import unhexlify, hexlify + +from pony.orm import db_session +from twisted.web import resource, http + +from Tribler.Core.Modules.restapi.metadata_endpoint import SpecificChannelTorrentsEndpoint + + +class BaseMyChannelEndpoint(resource.Resource): + + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + + +class MyChannelEndpoint(BaseMyChannelEndpoint): + + def __init__(self, session): + BaseMyChannelEndpoint.__init__(self, session) + self.putChild("torrents", MyChannelTorrentsEndpoint(session)) + self.putChild("commit", MyChannelCommitEndpoint(session)) + + def render_GET(self, request): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + return json.dumps({ + 'mychannel': { + 'public_key': hexlify(my_channel.public_key), + 'name': my_channel.title, + 'description': my_channel.tags, + 'dirty': my_channel.dirty + } + }) + + def render_POST(self, request): + parameters = http.parse_qs(request.content.read(), 1) + if 'name' not in parameters and 'description' not in parameters: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "name or description parameter missing"}) + + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + my_channel.update_metadata(update_dict={ + "tags": parameters['name'][0].encode('utf-8'), + "title": parameters['description'][0].encode('utf-8') + }) + + return json.dumps({"edited": True}) + + def render_PUT(self, request): + parameters = http.parse_qs(request.content.read(), 1) + + if 'name' not in parameters or not parameters['name'] or not parameters['name'][0]: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "channel name cannot be empty"}) + + if 'description' not in parameters or not parameters['description']: + description = u'' + else: + description = str(parameters['description'][0]).encode('utf-8') + + my_key = self.session.trustchain_keypair + my_channel_pk = my_key.pub().key_to_bin() + + # Do not allow to add a channel twice + if self.session.lm.mds.get_my_channel(): + request.setResponseCode(http.CONFLICT) + return json.dumps({"error": "channel already exists"}) + + title = str(parameters['name'][0]).encode('utf-8') + self.session.lm.mds.ChannelMetadata.create_channel(title, description) + return json.dumps({ + "added": str(my_channel_pk).encode("hex"), + }) + + +class MyChannelTorrentsEndpoint(BaseMyChannelEndpoint): + + def getChild(self, path, request): + return MyChannelSpecificTorrentEndpoint(self.session, path) + + def render_GET(self, request): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + request.args['channel'] = [str(my_channel.public_key).encode('hex')] + first, last, sort_by, sort_asc, query_filter, channel = \ + SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) + + torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( + first, last, sort_by, sort_asc, query_filter, channel) + torrents = [torrent.to_simple_dict(include_status=True) for torrent in torrents] + + return json.dumps({ + "torrents": torrents, + "first": first, + "last": last, + "sort_by": sort_by, + "sort_asc": int(sort_asc), + "total": total, + "dirty": my_channel.dirty + }) + + def render_POST(self, request): + parameters = http.parse_qs(request.content.read(), 1) + if 'status' not in parameters or 'infohashes' not in parameters: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "status or infohashes parameter missing"}) + + new_status = int(parameters['status'][0]) + infohashes = parameters['infohashes'][0].split(',') + + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + for infohash in infohashes: + torrent = my_channel.get_torrent(unhexlify(infohash)) + if not torrent: + continue + torrent.status = new_status + + return json.dumps({"success": True}) + + def render_DELETE(self, request): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + # Remove all torrents in your channel + torrents = my_channel.contents_list + for torrent in torrents: + my_channel.delete_torrent(torrent.infohash) + + return json.dumps({"success": True}) + + +class MyChannelSpecificTorrentEndpoint(BaseMyChannelEndpoint): + + def __init__(self, session, infohash): + BaseMyChannelEndpoint.__init__(self, session) + self.infohash = unhexlify(infohash) + + def render_PATCH(self, request): + parameters = http.parse_qs(request.content.read(), 1) + if 'status' not in parameters: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "status parameter missing"}) + + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + torrent = my_channel.get_torrent(self.infohash) + if not torrent: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "torrent with the specified infohash could not be found"}) + + new_status = int(parameters['status'][0]) + torrent.status = new_status + + return json.dumps({"success": True, "new_status": new_status}) + + +class MyChannelCommitEndpoint(BaseMyChannelEndpoint): + + def render_POST(self, request): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) + + my_channel.commit_channel_torrent() + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) + + return json.dumps({"success": True}) diff --git a/Tribler/Core/Modules/restapi/root_endpoint.py b/Tribler/Core/Modules/restapi/root_endpoint.py index c813cebd228..cd3d60f3e58 100644 --- a/Tribler/Core/Modules/restapi/root_endpoint.py +++ b/Tribler/Core/Modules/restapi/root_endpoint.py @@ -1,20 +1,18 @@ from twisted.web import resource -from Tribler.Core.Modules.restapi.channels.channels_endpoint import ChannelsEndpoint -from Tribler.Core.Modules.restapi.channels.my_channel_endpoint import MyChannelEndpoint from Tribler.Core.Modules.restapi.create_torrent_endpoint import CreateTorrentEndpoint from Tribler.Core.Modules.restapi.debug_endpoint import DebugEndpoint from Tribler.Core.Modules.restapi.downloads_endpoint import DownloadsEndpoint from Tribler.Core.Modules.restapi.events_endpoint import EventsEndpoint from Tribler.Core.Modules.restapi.libtorrent_endpoint import LibTorrentEndpoint from Tribler.Core.Modules.restapi.market_endpoint import MarketEndpoint +from Tribler.Core.Modules.restapi.metadata_endpoint import MetadataEndpoint +from Tribler.Core.Modules.restapi.mychannel_endpoint import MyChannelEndpoint from Tribler.Core.Modules.restapi.search_endpoint import SearchEndpoint from Tribler.Core.Modules.restapi.settings_endpoint import SettingsEndpoint from Tribler.Core.Modules.restapi.shutdown_endpoint import ShutdownEndpoint from Tribler.Core.Modules.restapi.state_endpoint import StateEndpoint from Tribler.Core.Modules.restapi.statistics_endpoint import StatisticsEndpoint -from Tribler.Core.Modules.restapi.torrentinfo_endpoint import TorrentInfoEndpoint -from Tribler.Core.Modules.restapi.torrents_endpoint import TorrentsEndpoint from Tribler.Core.Modules.restapi.trustchain_endpoint import TrustchainEndpoint from Tribler.Core.Modules.restapi.wallets_endpoint import WalletsEndpoint from Tribler.pyipv8.ipv8.REST.root_endpoint import RootEndpoint as IPV8RootEndpoint @@ -45,16 +43,21 @@ def start_endpoints(self): This method is only called when Tribler has started. It enables the other endpoints that are dependent on a fully started Tribler. """ - child_handler_dict = {"settings": SettingsEndpoint, "downloads": DownloadsEndpoint, - "createtorrent": CreateTorrentEndpoint, "torrents": TorrentsEndpoint, - "debug": DebugEndpoint, "shutdown": ShutdownEndpoint, "trustchain": TrustchainEndpoint, - "statistics": StatisticsEndpoint, "torrentinfo": TorrentInfoEndpoint, - "market": MarketEndpoint, "wallets": WalletsEndpoint, "libtorrent": LibTorrentEndpoint} - - if self.session.config.get_megacache_enabled(): - child_handler_dict["search"] = SearchEndpoint - child_handler_dict["channels"] = ChannelsEndpoint - child_handler_dict["mychannel"] = MyChannelEndpoint + child_handler_dict = { + "settings": SettingsEndpoint, + "downloads": DownloadsEndpoint, + "createtorrent": CreateTorrentEndpoint, + "debug": DebugEndpoint, + "shutdown": ShutdownEndpoint, + "trustchain": TrustchainEndpoint, + "statistics": StatisticsEndpoint, + "market": MarketEndpoint, + "wallets": WalletsEndpoint, + "libtorrent": LibTorrentEndpoint, + "metadata": MetadataEndpoint, + "mychannel": MyChannelEndpoint, + "search": SearchEndpoint + } for path, child_cls in child_handler_dict.iteritems(): self.putChild(path, child_cls(self.session)) diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index a1aecc4ef01..2122805dc88 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -1,31 +1,14 @@ from __future__ import absolute_import + import logging from binascii import unhexlify -from pony.orm import db_session, desc, select +from pony.orm import db_session + from twisted.web import http, resource import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, REGULAR_TORRENT -from Tribler.Core.Modules.restapi.util import convert_torrent_metadata_to_tuple, convert_db_torrent_to_json, \ - channel_to_torrent_adapter -from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_TORRENTS - -metadata_type_conversion_dict = {u'channel': CHANNEL_TORRENT, - u'torrent': REGULAR_TORRENT} - - -def shift_and_clamp(x, s): - return x - s if x > s else 0 - - -json2pony_columns = {u'category': "tags", - u'id': "rowid", - u'name': "title", - u'size': "size", - u'infohash': "infohash", - u'date': "torrent_date", - u'commit_status': 'status'} +from Tribler.util import cast_to_unicode_utf8 class SearchEndpoint(resource.Resource): @@ -44,6 +27,40 @@ def __init__(self, session): self.putChild("completions", SearchCompletionsEndpoint(session)) + @staticmethod + def convert_sort_param_to_pony_col(sort_param): + """ + Convert an incoming sort parameter to a pony column in the database. + :return a string with the right column. None if there exists no value for the given key. + """ + json2pony_columns = { + u'category': "tags", + u'id': "rowid", + u'name': "title", + u'health': "health", + } + + if sort_param not in json2pony_columns: + return None + return json2pony_columns[sort_param] + + @staticmethod + def sanitize_parameters(parameters): + """ + Sanitize the parameters and check whether they exist + """ + first = 1 if 'first' not in parameters else int(parameters['first'][0]) + last = 50 if 'last' not in parameters else int(parameters['last'][0]) + sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] + sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) + data_type = None if 'type' not in parameters else parameters['type'][0] + + if sort_by: + sort_by = SearchEndpoint.convert_sort_param_to_pony_col(sort_by) + + return first, last, sort_by, sort_asc, data_type + + @db_session def render_GET(self, request): """ .. http:get:: /search?q=(string:query) @@ -90,145 +107,46 @@ def render_GET(self, request): "chant_dirty":false } """ - first = 0 - last = None - item_type = None - channel_id = None - txt_search_query = None - sort_forward = True - sort_column = u'id' - sort_by = None - chant_dirty = False - subscribed = None - channel = None - - xxx_filter = self.session.config.get_family_filter_enabled() - - if 'first' in request.args and request.args['first'] > 0: - first = int(request.args['first'][0]) - - if 'last' in request.args and request.args['last'] > 0: - last = int(request.args['last'][0]) - - if 'type' in request.args and request.args['type'] > 0: - item_type = str(request.args['type'][0]) - - if 'channel' in request.args and request.args['channel'] > 0: - channel_id = unhexlify(request.args['channel'][0]) - - if 'sort_by' in request.args and request.args['sort_by'] > 0: - sort_by = request.args['sort_by'][0] - sort_forward = True #TODO(Martijn): fix correctly - sort_column = sort_by - - if 'txt' in request.args and request.args['txt'] > 0: - txt_search_query = request.args['txt'][0] - - if 'subscribed' in request.args and request.args['subscribed'] > 0: - subscribed = int(request.args['subscribed'][0]) - - results = [] - is_dispersy_channel = (len(channel_id) != 74) if channel_id else False - - # ACHTUNG! In its current form, the endpoint is carefully _designed_ to mix legacy and Pony results - # together correctly in regards to pagination! Befor sending results for a page, it considers the whole - # query size for _both_ legacy and Pony DBs, and then places the results correctly (Pony first, legacy last). - - # Legacy query for channel contents - if is_dispersy_channel: - channels_list = self.channel_db_handler.getChannelsByCID([channel_id]) - channel_info = channels_list[0] if channels_list else None - if channel_info is None: - return json.dumps({"error": "Channel with given Dispersy ID is not found"}) - - torrent_db_columns = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', - 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.inserted'] - results = self.channel_db_handler.getTorrentsFromChannelId(channel_info[0], True, torrent_db_columns, - first=first, last=last) + if 'q' not in request.args or not request.args['q'] or not request.args['q'][0]: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "q parameter missing"}) + + first, last, sort_by, sort_asc, data_type = SearchEndpoint.sanitize_parameters(request.args) + query = request.args['q'][0] + + torrent_results, total_torrents = self.session.lm.mds.TorrentMetadata.get_torrents( + first, last, sort_by, sort_asc, query_filter=query) + torrents_json = [] + for torrent in torrent_results: + torrent_json = torrent.to_simple_dict() + torrent_json['type'] = 'torrent' + torrents_json.append(torrent_json) + + channel_results, total_channels = self.session.lm.mds.ChannelMetadata.get_channels( + first, last, sort_by, sort_asc, query_filter=query) + channels_json = [] + for channel in channel_results: + channel_json = channel.to_simple_dict() + channel_json['type'] = 'channel' + channels_json.append(channel_json) + + if not data_type: + search_results = channels_json + torrents_json + elif data_type == 'channel': + search_results = channels_json + elif data_type == 'torrent': + search_results = torrents_json else: - with db_session: - # Object class to query - base_type = self.session.lm.mds.TorrentMetadata - if item_type == u'channel' or subscribed: - base_type = self.session.lm.mds.ChannelMetadata - - # Achtung! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere !!! - pony_query = select(g for g in base_type) - - # Add FTS search terms - if txt_search_query: - pony_query = base_type.search_keyword(txt_search_query + "*", lim=1000) - - # Filter by channel id - if channel_id: - channel = self.session.lm.mds.ChannelMetadata.get(public_key=channel_id) - chant_dirty = channel.dirty - if not channel: - return json.dumps({"error": "Channel with given public key is not found"}) - pony_query = pony_query.where(public_key=channel.public_key, metadata_type=REGULAR_TORRENT) - - # Filter by metadata type - if item_type: - if item_type not in metadata_type_conversion_dict: - return json.dumps({"error": "Unknown metadata type queried: " + item_type}) - pony_query = pony_query.where(metadata_type=metadata_type_conversion_dict[item_type]) - - # Filter subscribed/non-subscribed - if subscribed is not None: - pony_query = pony_query.where(subscribed=bool(subscribed)) - - pony_query_size = pony_query.count() - # Sort the query - if sort_by: - sort_expression = "g." + json2pony_columns[sort_column] - sort_expression = sort_expression if sort_forward else desc(sort_expression) - pony_query = pony_query.sort_by(sort_expression) - - pony_query_results = [convert_torrent_metadata_to_tuple(md) for md in pony_query[first:last]] - results.extend(pony_query_results) - - # Legacy query for subscribed channels - skip_dispersy = txt_search_query or (channel and not is_dispersy_channel) - if subscribed: - skip_dispersy = True - subscribed_channels_db = self.channel_db_handler.getMySubscribedChannels(include_dispersy=True) - results.extend([channel_to_torrent_adapter(c) for c in subscribed_channels_db]) - - previous_query_size = pony_query_size - if not skip_dispersy: - # Legacy query for channels - if item_type not in metadata_type_conversion_dict or item_type == u'channel': - first2 = shift_and_clamp(first, previous_query_size) - last2 = shift_and_clamp(last, previous_query_size) - dispersy_channels = [] - count = 0 - if txt_search_query: - dispersy_channels = self.channel_db_handler.search_in_local_channels_db(txt_search_query, - first=first2, - last=last2) - count = self.channel_db_handler.search_in_local_channels_db(txt_search_query, count=True)[0][0] - elif not channel_id: - dispersy_channels = self.channel_db_handler.getAllChannels(first=first, last=last) - count = self.channel_db_handler.getAllChannelsCount()[0][0] - results.extend([channel_to_torrent_adapter(c) for c in dispersy_channels]) - previous_query_size += count - - # Legacy query for torrents - if (item_type not in metadata_type_conversion_dict or item_type == u'torrent') and not channel_id: - first3 = shift_and_clamp(first, previous_query_size) - last3 = shift_and_clamp(last, previous_query_size) - torrent_db_columns = ['T.torrent_id', 'infohash', 'T.name', 'length', 'category', - 'num_seeders', 'num_leechers', 'last_tracker_check'] - dispersy_torrents = self.torrent_db_handler.search_in_local_torrents_db(txt_search_query, - keys=torrent_db_columns, - first=first3, - last=last3, - family_filter=xxx_filter) - results.extend(dispersy_torrents) - - results_json = [convert_db_torrent_to_json(t) for t in results] - - return json.dumps({"torrents": results_json, "chant_dirty": chant_dirty}) + search_results = [] + + return json.dumps({ + "results": search_results[first - 1:last], + "first": first, + "last": last, + "sort_by": sort_by, + "sort_asc": sort_asc, + "total": total_torrents + total_channels + }) class SearchCompletionsEndpoint(resource.Resource): @@ -239,7 +157,6 @@ class SearchCompletionsEndpoint(resource.Resource): def __init__(self, session): resource.Resource.__init__(self) self.session = session - self.torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) def render_GET(self, request): """ @@ -267,7 +184,6 @@ def render_GET(self, request): request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "query parameter missing"}) - keywords = unicode(request.args['q'][0], 'utf-8').lower() - results = self.torrent_db_handler.getAutoCompleteTerms(keywords, max_terms=5) - results.extend(self.session.lm.mds.TorrentMetadata.get_auto_complete_terms(keywords, max_terms=5)) + keywords = cast_to_unicode_utf8(request.args['q'][0]).lower() + results = self.session.lm.mds.TorrentMetadata.get_auto_complete_terms(keywords, max_terms=5) return json.dumps({"completions": results}) diff --git a/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py b/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py deleted file mode 100644 index a9955514c7a..00000000000 --- a/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py +++ /dev/null @@ -1,175 +0,0 @@ -from __future__ import absolute_import - -import hashlib -import logging -from libtorrent import bdecode, bencode - -from six import text_type -from six.moves.urllib.request import url2pathname -from twisted.internet.defer import Deferred -from twisted.internet.error import DNSLookupError, ConnectError, ConnectionLost -from twisted.web import http, resource -from twisted.web.server import NOT_DONE_YET - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION -from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, \ - REGULAR_TORRENT, read_payload -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.utilities import fix_torrent, http_get, parse_magnetlink, unichar_string -from Tribler.Core.exceptions import HttpError, InvalidSignatureException - - -class TorrentInfoEndpoint(resource.Resource): - """ - This endpoint is responsible for handing all requests regarding torrent info in Tribler. - """ - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - self._logger = logging.getLogger(self.__class__.__name__) - - def finish_request(self, request): - try: - request.finish() - except RuntimeError: - self._logger.warning("Writing response failed, probably the client closed the connection already.") - - def render_GET(self, request): - """ - .. http:get:: /torrentinfo - - A GET request to this endpoint will return information from a torrent found at a provided URI. - This URI can either represent a file location, a magnet link or a HTTP(S) url. - - torrent: the URI of the torrent file that should be downloaded. This parameter is required. - - **Example request**: - - .. sourcecode:: none - - curl -X PUT http://localhost:8085/torrentinfo?torrent=file:/home/me/test.torrent - - **Example response**: - - .. sourcecode:: javascript - - {"metainfo": } - """ - - def on_got_metainfo(metainfo): - if not isinstance(metainfo, dict) or 'info' not in metainfo: - self._logger.warning("Received metainfo is not a valid dictionary") - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - request.write(json.dumps({"error": 'invalid response'})) - self.finish_request(request) - return - - infohash = hashlib.sha1(bencode(metainfo['info'])).digest() - - # Check if the torrent is already in the downloads - metainfo['download_exists'] = infohash in self.session.lm.downloads - - # Update the torrent database with metainfo if it is an unnamed torrent - if self.session.lm.torrent_db: - self.session.lm.torrent_db.update_torrent_with_metainfo(infohash, metainfo) - self.session.lm.torrent_db._db.commit_now() - - # Save the torrent to our store - try: - self.session.save_collected_torrent(infohash, bencode(metainfo)) - except TypeError: - # Note: in libtorrent 1.1.1, bencode throws a TypeError which is a known bug - pass - - request.write(json.dumps({"metainfo": metainfo}, ensure_ascii=False)) - self.finish_request(request) - - def on_metainfo_timeout(_): - if not request.finished: - request.setResponseCode(http.REQUEST_TIMEOUT) - request.write(json.dumps({"error": "timeout"})) - # If the above request.write failed, the request will have already been finished - if not request.finished: - self.finish_request(request) - - def on_lookup_error(failure): - failure.trap(ConnectError, DNSLookupError, HttpError, ConnectionLost) - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - request.write(json.dumps({"error": unichar_string(failure.getErrorMessage())})) - self.finish_request(request) - - def _on_loaded(response): - if response.startswith('magnet'): - _, infohash, _ = parse_magnetlink(response) - if infohash: - self.session.lm.ltmgr.get_metainfo(response, callback=metainfo_deferred.callback, timeout=20, - timeout_callback=on_metainfo_timeout, notify=True) - return - metainfo_deferred.callback(bdecode(response)) - - def on_mdblob(filename): - try: - with open(filename, 'rb') as f: - serialized_data = f.read() - payload = read_payload(serialized_data) - if payload.metadata_type not in [REGULAR_TORRENT, CHANNEL_TORRENT]: - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "Non-torrent metadata type"}) - magnet = payload.get_magnet() - except InvalidSignatureException: - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "metadata has incorrect signature"}) - else: - return on_magnet(magnet) - - def on_file(): - try: - filename = url2pathname(uri[5:].encode('utf-8') if isinstance(uri, text_type) else uri[5:]) - if filename.endswith(BLOB_EXTENSION): - return on_mdblob(filename) - metainfo_deferred.callback(bdecode(fix_torrent(filename))) - return NOT_DONE_YET - except TypeError: - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "error while decoding torrent file"}) - - def on_magnet(mlink=None): - infohash = parse_magnetlink(mlink or uri)[1] - if infohash is None: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "missing infohash"}) - - if self.session.has_collected_torrent(infohash): - try: - tdef = TorrentDef.load_from_memory(self.session.get_collected_torrent(infohash)) - except ValueError as exc: - request.setResponseCode(http.INTERNAL_SERVER_ERROR) - return json.dumps({"error": "invalid torrent file: %s" % str(exc)}) - on_got_metainfo(tdef.get_metainfo()) - return NOT_DONE_YET - - self.session.lm.ltmgr.get_metainfo(mlink or uri, callback=metainfo_deferred.callback, timeout=20, - timeout_callback=on_metainfo_timeout, notify=True) - return NOT_DONE_YET - - metainfo_deferred = Deferred() - metainfo_deferred.addCallback(on_got_metainfo) - - if 'uri' not in request.args or not request.args['uri']: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "uri parameter missing"}) - - uri = unicode(request.args['uri'][0], 'utf-8') - - if uri.startswith('file:'): - return on_file() - elif uri.startswith('http'): - http_get(uri.encode('utf-8')).addCallback(_on_loaded).addErrback(on_lookup_error) - elif uri.startswith('magnet'): - return on_magnet() - else: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "invalid uri"}) - - return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/torrents_endpoint.py b/Tribler/Core/Modules/restapi/torrents_endpoint.py deleted file mode 100644 index af868c4b94c..00000000000 --- a/Tribler/Core/Modules/restapi/torrents_endpoint.py +++ /dev/null @@ -1,350 +0,0 @@ -from __future__ import absolute_import - -import logging - -from pony.orm import db_session - -from twisted.web import http, resource -from twisted.web.server import NOT_DONE_YET - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.restapi.util import convert_db_torrent_to_json -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.simpledefs import NTFY_TORRENTS, NTFY_CHANNELCAST -from Tribler.pyipv8.ipv8.database import database_blob - - -class TorrentsEndpoint(resource.Resource): - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - - def getChild(self, path, request): - if path == "random": - return TorrentsRandomEndpoint(self.session) - return SpecificTorrentEndpoint(self.session, path) - - -class TorrentsRandomEndpoint(resource.Resource): - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - self.torrents_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - - def render_GET(self, request): - """ - .. http:get:: /torrents/random?limit=(int: max nr of torrents) - - A GET request to this endpoint returns random (channel) torrents. - You can optionally specify a limit parameter to limit the maximum number of results. By default, this is 10. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/torrents/random?limit=1 - - **Example response**: - - .. sourcecode:: javascript - - { - "torrents": [{ - "id": 4, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779", - "name": "Ubuntu-16.04-desktop-amd64", - "size": 8592385, - "category": "other", - "num_seeders": 42, - "num_leechers": 184, - "last_tracker_check": 1463176959 - }] - } - """ - limit_torrents = 10 - - if 'limit' in request.args and len(request.args['limit']) > 0: - limit_torrents = int(request.args['limit'][0]) - - if limit_torrents <= 0: - request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "the limit parameter must be a positive number"}) - - torrent_db_columns = ['Torrent.torrent_id', 'infohash', 'Torrent.name', 'length', 'Torrent.category', - 'num_seeders', 'num_leechers', 'last_tracker_check', 'ChannelTorrents.inserted'] - - popular_torrents = self.channel_db_handler.get_random_channel_torrents(torrent_db_columns, limit=limit_torrents) - - results_json = [] - for popular_torrent in popular_torrents: - torrent_json = convert_db_torrent_to_json(popular_torrent) - if (self.session.config.get_family_filter_enabled() and - self.session.lm.category.xxx_filter.isXXX(torrent_json['category'])) \ - or torrent_json['name'] is None \ - or torrent_json['infohash'] in self.session.lm.downloads: - continue - - results_json.append(torrent_json) - - return json.dumps({"torrents": results_json}) - - -class SpecificTorrentEndpoint(resource.Resource): - """ - This class handles requests for a specific torrent. - """ - - def __init__(self, session, infohash): - resource.Resource.__init__(self) - self.session = session - self.infohash = infohash - self.torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - - self.putChild("health", TorrentHealthEndpoint(self.session, self.infohash)) - self.putChild("trackers", TorrentTrackersEndpoint(self.session, self.infohash)) - - def render_GET(self, request): - """ - .. http:get:: /torrents/(string: torrent infohash) - - Get information of a torrent with a given infohash from a given channel. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779 - - **Example response**: - - .. sourcecode:: javascript - - { - "id": 4, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779", - "name": "Ubuntu-16.04-desktop-amd64", - "size": 8592385, - "category": "other", - "num_seeders": 42, - "num_leechers": 184, - "last_tracker_check": 1463176959, - "files": [{"path": "test.txt", "length": 1234}, ...], - "trackers": ["http://tracker.org:8080", ...] - } - - :statuscode 404: if the torrent is not found in the specified channel - """ - torrent_db_columns = ['C.torrent_id', 'infohash', 'name', 'length', 'category', - 'num_seeders', 'num_leechers', 'last_tracker_check'] - torrent_info = self.torrent_db_handler.getTorrent(self.infohash.decode('hex'), keys=torrent_db_columns) - if torrent_info is None: - # Maybe this is a chant torrent? - infohash = self.infohash.decode('hex') - with db_session: - md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: - g.infohash == database_blob(infohash))) - if md_list: - torrent_md = md_list[0] # Any MD containing this infohash is fine - # FIXME: replace these placeholder values when Dispersy is gone - torrent_info = { - "C.torrent_id": "", - "name": torrent_md.title, - "length": torrent_md.size, - "category": torrent_md.tags.split(",")[0] or '', - "last_tracker_check": 0, - "num_seeders": 0, - "num_leechers": 0 - } - - if torrent_info is None: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "Unknown torrent"}) - - torrent_files = [] - for path, length in self.torrent_db_handler.getTorrentFiles(torrent_info['C.torrent_id']): - torrent_files.append({"path": path, "size": length}) - - torrent_json = { - "id": torrent_info['C.torrent_id'], - "infohash": self.infohash, - "name": torrent_info['name'], - "size": torrent_info['length'], - "category": torrent_info['category'], - "num_seeders": torrent_info['num_seeders'] if torrent_info['num_seeders'] else 0, - "num_leechers": torrent_info['num_leechers'] if torrent_info['num_leechers'] else 0, - "last_tracker_check": torrent_info['last_tracker_check'], - "files": torrent_files, - "trackers": self.torrent_db_handler.getTrackerListByTorrentID(torrent_info['C.torrent_id']) - } - - return json.dumps(torrent_json) - - -class TorrentTrackersEndpoint(resource.Resource): - """ - This class is responsible for fetching all trackers of a specific torrent. - """ - - def __init__(self, session, infohash): - resource.Resource.__init__(self) - self.session = session - self.infohash = infohash - self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - - def render_GET(self, request): - """ - .. http:get:: /torrents/(string: torrent infohash)/tracker - - Fetch all trackers of a specific torrent. - - **Example request**: - - .. sourcecode:: none - - curl http://localhost:8085/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779/trackers - - **Example response**: - - .. sourcecode:: javascript - - { - "trackers": [ - "http://mytracker.com:80/announce", - "udp://fancytracker.org:1337/announce" - ] - } - - :statuscode 404: if the torrent is not found in the database - """ - torrent_info = self.torrent_db.getTorrent(self.infohash.decode('hex'), ['C.torrent_id', 'num_seeders']) - - if torrent_info is None: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "torrent not found in database"}) - - trackers = self.torrent_db.getTrackerListByInfohash(self.infohash.decode('hex')) - return json.dumps({"trackers": trackers}) - - -class TorrentHealthEndpoint(resource.Resource): - """ - This class is responsible for endpoints regarding the health of a torrent. - """ - - def __init__(self, session, infohash): - resource.Resource.__init__(self) - self.session = session - self.infohash = infohash - self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - self._logger = logging.getLogger(self.__class__.__name__) - - def finish_request(self, request): - try: - request.finish() - except RuntimeError: - self._logger.warning("Writing response failed, probably the client closed the connection already.") - - def render_GET(self, request): - """ - .. http:get:: /torrents/(string: torrent infohash)/health - - Fetch the swarm health of a specific torrent. You can optionally specify the timeout to be used in the - connections to the trackers. This is by default 20 seconds. - By default, we will not check the health of a torrent again if it was recently checked. You can force a health - recheck by passing the refresh parameter. - - **Example request**: - - .. sourcecode:: none - - curl http://localhost:8085/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779/health?timeout=15&refresh=1 - - **Example response**: - - .. sourcecode:: javascript - - { - "http://mytracker.com:80/announce": [{ - "seeders": 43, - "leechers": 20, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779" - }], - "http://nonexistingtracker.com:80/announce": { - "error": "timeout" - } - } - - :statuscode 404: if the torrent is not found in the database - """ - timeout = 20 - if 'timeout' in request.args: - timeout = int(request.args['timeout'][0]) - - refresh = False - if 'refresh' in request.args and len(request.args['refresh']) > 0 and request.args['refresh'][0] == "1": - refresh = True - - torrent_db_columns = ['C.torrent_id', 'num_seeders', 'num_leechers', 'next_tracker_check'] - torrent_info = self.torrent_db.getTorrent(self.infohash.decode('hex'), torrent_db_columns) - - def on_health_result(result): - request.write(json.dumps({'health': result})) - self.finish_request(request) - - def on_magnet_timeout_error(_): - if not request.finished: - request.setResponseCode(http.NOT_FOUND) - request.write(json.dumps({"error": "torrent not found in database"})) - if not request.finished: - self.finish_request(request) - - def on_request_error(failure): - if not request.finished: - request.setResponseCode(http.BAD_REQUEST) - request.write(json.dumps({"error": failure.getErrorMessage()})) - # If the above request.write failed, the request will have already been finished - if not request.finished: - self.finish_request(request) - - def make_torrent_health_request(): - self.session.check_torrent_health(self.infohash.decode('hex'), timeout=timeout, scrape_now=refresh) \ - .addCallback(on_health_result).addErrback(on_request_error) - - magnet = None - if torrent_info is None: - # Maybe this is a chant torrent? - infohash = self.infohash.decode('hex') - with db_session: - md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: - g.infohash == database_blob(infohash))) - if md_list: - torrent_md = md_list[0] # Any MD containing this infohash is fine - magnet = torrent_md.get_magnet() - if 'timeout' in request.args: - timeout = int(request.args['timeout'][0]) - else: - timeout = 50 - - def _add_torrent_and_check(metainfo): - tdef = TorrentDef.load_from_dict(metainfo) - assert (tdef.infohash == infohash), "DHT infohash does not match locally generated one" - self._logger.info("Chant-managed torrent fetched from DHT. Adding it to local cache, %s", self.infohash) - self.session.lm.torrent_db.addExternalTorrent(tdef) - self.session.lm.torrent_db._db.commit_now() - make_torrent_health_request() - - if magnet: - # Try to get the torrent from DHT and add it to the local cache - self._logger.info("Chant-managed torrent not in cache. Going to fetch it from DHT, %s", self.infohash) - self.session.lm.ltmgr.get_metainfo(magnet, callback=_add_torrent_and_check, - timeout=timeout, timeout_callback=on_magnet_timeout_error, notify=False) - elif torrent_info is None: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "torrent not found in database"}) - else: - make_torrent_health_request() - - return NOT_DONE_YET diff --git a/Tribler/Core/Session.py b/Tribler/Core/Session.py index 6686ac0dbb6..020f0500fb6 100644 --- a/Tribler/Core/Session.py +++ b/Tribler/Core/Session.py @@ -17,7 +17,6 @@ from twisted.python.threadable import isInIOThread import Tribler.Core.permid as permid_module -from Tribler.Core import NoDispersyRLock from Tribler.Core.APIImplementation.LaunchManyCore import TriblerLaunchMany from Tribler.Core.CacheDB.Notifier import Notifier from Tribler.Core.CacheDB.sqlitecachedb import DB_DIR_NAME, DB_FILE_RELATIVE_PATH, SQLiteCacheDB @@ -29,14 +28,15 @@ from Tribler.Core.exceptions import DuplicateTorrentFileError, NotYetImplementedException, \ OperationNotEnabledByConfigurationException from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, NTFY_DELETE, NTFY_INSERT, NTFY_MYPREFERENCES, NTFY_PEERS, - NTFY_TORRENTS, NTFY_TRIBLER, NTFY_UPDATE, NTFY_VOTECAST, STATEDIR_DLPSTATE_DIR, + STATEDIR_CHANNELS_DIR, NTFY_TORRENTS, NTFY_TRIBLER, NTFY_UPDATE, NTFY_VOTECAST, + STATEDIR_DLPSTATE_DIR, STATEDIR_WALLET_DIR, STATE_LOAD_CHECKPOINTS, STATE_OPEN_DB, STATE_READABLE_STARTED, STATE_SHUTDOWN, STATE_START_API, STATE_UPGRADING_READABLE) from Tribler.Core.statistics import TriblerStatistics from Tribler.pyipv8.ipv8.util import cast_to_long try: - long # pylint: disable=long-builtin + long # pylint: disable=long-builtin except NameError: long = int # pylint: disable=redefined-builtin @@ -98,6 +98,7 @@ def __init__(self, config=None, autoload_discovery=True): def create_state_directory_structure(self): """Create directory structure of the state directory.""" + def create_dir(path): if not os.path.isdir(path): os.makedirs(path) @@ -111,6 +112,7 @@ def create_in_state_dir(path): create_in_state_dir(DB_DIR_NAME) create_in_state_dir(STATEDIR_DLPSTATE_DIR) create_in_state_dir(STATEDIR_WALLET_DIR) + create_in_state_dir(STATEDIR_CHANNELS_DIR) def get_ports_in_config(self): """Claim all required random ports.""" diff --git a/Tribler/Core/simpledefs.py b/Tribler/Core/simpledefs.py index e52e06ec2af..23986be499c 100644 --- a/Tribler/Core/simpledefs.py +++ b/Tribler/Core/simpledefs.py @@ -34,6 +34,7 @@ STATEDIR_DLPSTATE_DIR = u'dlcheckpoints' STATEDIR_WALLET_DIR = u'wallet' +STATEDIR_CHANNELS_DIR = u'channels' # For observer/callback mechanism, see Session.add_observer() # subjects diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index 9520a4108bf..9d576851974 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -4,11 +4,13 @@ from datetime import datetime from pony.orm import db_session + from six.moves import xrange + from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH, \ - ROOT_CHANNEL_ID +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import CHANNEL_DIR_NAME_LENGTH, ROOT_CHANNEL_ID, \ + entries_to_chunk from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore @@ -202,14 +204,14 @@ def test_delete_torrent_from_channel(self): # Check that nothing is committed when deleting uncommited torrent metadata channel_metadata.add_torrent_to_channel(tdef, None) - channel_metadata.delete_torrent_from_channel(tdef.get_infohash()) + channel_metadata.delete_torrent(tdef.get_infohash()) self.assertEqual(0, len(channel_metadata.contents_list)) # Check append-only deletion process channel_metadata.add_torrent_to_channel(tdef, None) channel_metadata.commit_channel_torrent() self.assertEqual(1, len(channel_metadata.contents_list)) - channel_metadata.delete_torrent_from_channel(tdef.get_infohash()) + channel_metadata.delete_torrent(tdef.get_infohash()) channel_metadata.commit_channel_torrent() self.assertEqual(0, len(channel_metadata.contents_list)) @@ -232,7 +234,7 @@ def test_consolidate_channel_torrent(self): channel.commit_channel_torrent() # Delete entry - channel.delete_torrent_from_channel(tdef.get_infohash()) + channel.delete_torrent(tdef.get_infohash()) channel.commit_channel_torrent() self.assertEqual(1, len(channel.contents_list)) @@ -244,3 +246,30 @@ def test_mdblob_dont_fit_exception(self): with db_session: md_list = [self.mds.TorrentMetadata(title='test' + str(x)) for x in xrange(0, 1)] self.assertRaises(Exception, entries_to_chunk, md_list, chunk_size=1) + + @db_session + def test_get_channels(self): + """ + Test whether we can get channels + """ + + # First we create a few channels + for ind in xrange(10): + self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) + channels = self.mds.ChannelMetadata.get_channels(first=1, last=5) + self.assertEqual(len(channels[0]), 5) + self.assertEqual(channels[1], 10) + + # Test filtering + channels = self.mds.ChannelMetadata.get_channels(first=1, last=5, query_filter='channel5') + self.assertEqual(len(channels[0]), 1) + + # Test sorting + channels = self.mds.ChannelMetadata.get_channels(first=1, last=10, sort_by='title', sort_asc=False) + self.assertEqual(len(channels[0]), 10) + self.assertEqual(channels[0][0].title, 'channel9') + + # Test fetching subscribed channels + channels = self.mds.ChannelMetadata.get_channels(first=1, last=10, sort_by='title', subscribed=True) + self.assertEqual(len(channels[0]), 5) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 6aa39497834..9c6e1692d5c 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import os +from binascii import hexlify from datetime import datetime from pony.orm import db_session @@ -155,3 +156,26 @@ def test_get_autocomplete_terms_max(self): autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms("sheep", 2) self.assertEqual(len(autocomplete_terms), 2) + + @db_session + def test_get_torrents(self): + """ + Test whether we can get torrents + """ + + # First we create a few channels and add some torrents to these channels + for ind in xrange(5): + self.mds.Metadata._my_key = default_eccrypto.generate_key('curve25519') + _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) + for torrent_ind in xrange(5): + _ = self.mds.TorrentMetadata(title='torrent%d' % torrent_ind) + + torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=5) + self.assertEqual(len(torrents[0]), 5) + self.assertEqual(torrents[1], 25) + + # Test fetching torrents in a channel + channel_pk = self.mds.Metadata._my_key.pub().key_to_bin()[10:] + torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=10, sort_by='title', channel_pk=channel_pk) + self.assertEqual(len(torrents[0]), 5) + self.assertEqual(torrents[1], 5) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/__init__.py b/Tribler/Test/Core/Modules/RestApi/Channels/__init__.py deleted file mode 100644 index f01f634857e..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests for the channels endpoints. -""" diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py deleted file mode 100644 index bbf83977620..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_discovered_endpoint.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import absolute_import - -import json -import os -import random -from binascii import hexlify - -import six -from pony.orm import db_session - -from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG -from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import ALREADY_SUBSCRIBED_RESPONSE_MSG -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ - AbstractTestChantEndpoint -from Tribler.Test.test_as_server import TESTS_DATA_DIR -from Tribler.Test.tools import trial_timeout -from Tribler.pyipv8.ipv8.database import database_blob - - -class TestChannelsDiscoveredEndpoints(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_get_channel_info_non_existent(self): - """ - Testing whether the API returns error 404 if an unknown channel is queried - """ - self.should_check_equality = True - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/discovered/aabb', expected_code=404, expected_json=expected_json) - - @trial_timeout(10) - def test_get_channel_info(self): - """ - Testing whether the API returns the right JSON data if a channel overview is requested - """ - channel_json = {u'overview': {u'name': u'testname', u'description': u'testdescription', - u'identifier': six.text_type(hexlify(b'fake'))}} - self.insert_channel_in_db('fake', 3, channel_json[u'overview'][u'name'], - channel_json[u'overview'][u'description']) - - return self.do_request('channels/discovered/%s' % hexlify(b'fake'), expected_code=200, - expected_json=channel_json) - - -class TestChannelsDiscoveredChantEndpoints(AbstractTestChantEndpoint): - - @trial_timeout(10) - def test_create_my_channel(self): - """ - Test whether we can create a new chant channel using the API - """ - - def verify_created(_): - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - self.assertTrue(self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id)) - - post_params = {'name': 'test1', 'description': 'test'} - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=200, expected_json={}, - post_data=post_params, request_type='PUT').addCallback(verify_created) - - @trial_timeout(10) - def test_create_my_channel_twice(self): - """ - Test whether the API returns error 500 when we try to add a channel twice - """ - self.create_my_channel('test', 'test2') - post_params = {'name': 'test1', 'description': 'test'} - self.should_check_equality = False - return self.do_request('channels/discovered', expected_code=500, expected_json={}, - post_data=post_params, request_type='PUT') - - @trial_timeout(10) - def test_export_channel_mdblob(self): - """ - Test if export of a channel .mdblob through the endpoint works correctly - """ - with open(os.path.join(TESTS_DATA_DIR, 'channel.mdblob'), 'rb') as f: - mdblob = f.read() - payload = ChannelMetadataPayload.from_signed_blob(mdblob) - with db_session: - self.session.lm.mds.ChannelMetadata.from_payload(payload) - - def verify_exported_data(result): - self.assertEqual(mdblob, result) - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/mdblob' % hexlify(payload.public_key), - expected_code=200, request_type='GET').addCallback(verify_exported_data) - - @trial_timeout(10) - def test_export_channel_mdblob_notfound(self): - """ - Test if export of a channel .mdblob through the endpoint works correctly - """ - with open(os.path.join(TESTS_DATA_DIR, 'channel.mdblob'), 'rb') as f: - mdblob = f.read() - payload = ChannelMetadataPayload.from_signed_blob(mdblob) - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/mdblob' % hexlify(payload.public_key), - expected_code=404, request_type='GET') - - @trial_timeout(10) - def test_subscribe_channel_already_subscribed(self): - """ - Testing whether the API returns error 409 when subscribing to an already subscribed channel - """ - with db_session: - channel = self.add_random_channel() - channel.subscribed = True - channel_public_key = channel.public_key - expected_json = {"error": ALREADY_SUBSCRIBED_RESPONSE_MSG} - - return self.do_request('channels/subscribed/%s' % str(channel_public_key).encode('hex'), - expected_code=409, expected_json=expected_json, request_type='PUT') - - @trial_timeout(10) - def test_remove_single_torrent(self): - """ - Testing whether the API can remove a single selected torrent from a channel - """ - with db_session: - channel = self.create_my_channel("bla", "bla") - channel_public_key = channel.public_key - torrent = self.add_random_torrent_to_my_channel() - torrent_infohash = torrent.infohash - - def verify_torrent_removed(response): - json_response = json.loads(response) - self.assertTrue(json_response["removed"], "Removing selected torrents failed") - with db_session: - self.assertEqual(len(channel.contents[:]), 0) - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % (hexlify(str(channel_public_key)), hexlify(str(torrent_infohash))) - - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) - - @trial_timeout(10) - def test_remove_multiple_torrents(self): - """ - Testing whether the API can remove multiple selected torrents from a channel - """ - with db_session: - channel = self.create_my_channel("bla", "bla") - channel_public_key = channel.public_key - torrent1 = self.add_random_torrent_to_my_channel() - torrent2 = self.add_random_torrent_to_my_channel() - torrent1_infohash = torrent1.infohash - torrent2_infohash = torrent2.infohash - - def verify_torrent_removed(response): - json_response = json.loads(response) - self.assertTrue(json_response["removed"], "Removing selected torrents failed") - with db_session: - self.assertEqual(len(channel.contents[:]), 0) - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % (str(channel_public_key).encode('hex'), - str(torrent1_infohash).encode('hex') + ',' + str( - torrent2_infohash).encode('hex')) - - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) - - @trial_timeout(10) - def test_remove_wrong_channel(self): - """ - Testing whether the API returns correct error message in case the channel public key is wrong - """ - with db_session: - self.create_my_channel("bla", "bla") - url = 'channels/discovered/%s/torrents/%s' % (hexlify('123'), hexlify(str('123'))) - return self.do_request(url, expected_code=405, request_type='DELETE') - - @trial_timeout(10) - def test_remove_nonexistent_channel(self): - """ - Testing whether the API returns correct error message in case the personal channel is not created yet - """ - with db_session: - channel = self.create_my_channel("bla", "bla") - channel_pubkey = channel.public_key - channel.delete() - url = 'channels/discovered/%s/torrents/%s' % (hexlify(str(channel_pubkey)), hexlify(str('123'))) - return self.do_request(url, expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_remove_unknown_infohash(self): - """ - Testing whether the API returns {"removed": False, "failed_torrents":[ infohash ]} if an unknown torrent is - removed from a channel - """ - with db_session: - channel = self.create_my_channel("bla", "bla") - channel_public_key = channel.public_key - unknown_torrent_infohash = database_blob(bytearray(random.getrandbits(8) for _ in range(20))) - - def verify_torrent_removed(response): - json_response = json.loads(response) - self.assertFalse(json_response["removed"], "Tribler removed an unknown torrent") - self.assertTrue(str(unknown_torrent_infohash).encode('hex') in json_response["failed_torrents"]) - - self.should_check_equality = False - url = 'channels/discovered/%s/torrents/%s' % ( - str(channel_public_key).encode('hex'), str(unknown_torrent_infohash).encode('hex')) - - return self.do_request(url, expected_code=200, request_type='DELETE').addCallback(verify_torrent_removed) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py deleted file mode 100644 index 0b0379e6a16..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_endpoint.py +++ /dev/null @@ -1,123 +0,0 @@ -from __future__ import absolute_import - -import random - -from pony.orm import db_session -from six.moves import xrange -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW -from Tribler.Core.Modules.channel.channel import ChannelObject -from Tribler.Core.Modules.channel.channel_manager import ChannelManager -from Tribler.Core.exceptions import DuplicateChannelNameError -from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest -from Tribler.Test.Core.base_test_channel import BaseTestChannel -from Tribler.Test.tools import trial_timeout -from Tribler.pyipv8.ipv8.database import database_blob -from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto - - -class ChannelCommunityMock(object): - - def __init__(self, channel_id, name, description, mode): - self.cid = 'a' * 20 - self._channel_id = channel_id - self._channel_name = name - self._channel_description = description - self._channel_mode = mode - - def get_channel_id(self): - return self._channel_id - - def get_channel_name(self): - return self._channel_name - - def get_channel_description(self): - return self._channel_description - - def get_channel_mode(self): - return self._channel_mode - - -class AbstractTestChantEndpoint(AbstractApiTest): - - def setUpPreSession(self): - super(AbstractTestChantEndpoint, self).setUpPreSession() - self.config.set_libtorrent_enabled(True) - self.config.set_chant_enabled(True) - - @db_session - def create_my_channel(self, name, description): - """ - Create your channel, with a given name and description. - """ - return self.session.lm.mds.ChannelMetadata.create_channel(name, description) - - @db_session - def add_random_torrent_to_my_channel(self, name=None): - """ - Add a random torrent to your channel. - """ - return self.session.lm.mds.TorrentMetadata(status=NEW, title='test' if not name else name, - infohash=database_blob( - bytearray(random.getrandbits(8) for _ in xrange(20)))) - - @db_session - def add_random_channel(self): - """ - Add a random channel to the metadata store. - :return: The metadata of the added channel. - """ - rand_key = default_eccrypto.generate_key('low') - new_channel = self.session.lm.mds.ChannelMetadata( - sign_with=rand_key, - public_key=database_blob(rand_key.pub().key_to_bin()), title='test', tags='test') - new_channel.sign(rand_key) - return new_channel - - @db_session - def get_my_channel(self): - """ - Return the metadata object of your channel, or None if it does not exist yet. - """ - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - return self.session.lm.mds.ChannelMetadata.get_channel_with_id(my_channel_id) - - -class AbstractTestChannelsEndpoint(AbstractApiTest, BaseTestChannel): - - @inlineCallbacks - def setUp(self): - yield super(AbstractTestChannelsEndpoint, self).setUp() - self.channel_db_handler._get_my_dispersy_cid = lambda: "myfakedispersyid" - - def vote_for_channel(self, cid, vote_time): - self.votecast_db_handler.on_votes_from_dispersy([[cid, None, 'random', 2, vote_time]]) - - def create_my_channel(self, name, description): - self.channel_db_handler._get_my_dispersy_cid = lambda: "myfakedispersyid" - self.channel_db_handler.on_channel_from_dispersy('fakedispersyid', None, name, description) - return self.channel_db_handler.getMyChannelId() - - def create_fake_channel(self, name, description, mode=u'closed'): - # Use a fake ChannelCommunity object (we don't actually want to create a Dispersy community) - my_channel_id = self.create_my_channel(name, description) - self.session.lm.channel_manager = ChannelManager(self.session) - - channel_obj = ChannelObject(self.session, ChannelCommunityMock(my_channel_id, name, description, mode)) - self.session.lm.channel_manager._channel_list.append(channel_obj) - return my_channel_id - - def create_fake_channel_with_existing_name(self, name, description, mode=u'closed'): - raise DuplicateChannelNameError(u"Channel name already exists: %s" % name) - - -class TestChannelsEndpoint(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_channels_unknown_endpoint(self): - """ - Testing whether the API returns an error if an unknown endpoint is queried - """ - self.should_check_equality = False - return self.do_request('channels/thisendpointdoesnotexist123', expected_code=404) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_playlist_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_playlist_endpoint.py deleted file mode 100644 index 18530a7ee17..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_playlist_endpoint.py +++ /dev/null @@ -1,488 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG -import Tribler.Core.Utilities.json_util as json -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.tools import trial_timeout -from Tribler.dispersy.exception import CommunityNotFoundException - - -class AbstractTestChannelsPlaylistsEndpoint(AbstractTestChannelsEndpoint): - """ - This class is the base class for all playlist-related tests. - """ - - def create_playlist(self, channel_id, dispersy_id, peer_id, name, description): - self.channel_db_handler.on_playlist_from_dispersy(channel_id, dispersy_id, peer_id, name, description) - - def insert_torrent_into_playlist(self, playlist_disp_id, infohash): - self.channel_db_handler.on_playlist_torrent(42, playlist_disp_id, 42, infohash) - - -class TestChannelsPlaylistEndpoints(AbstractTestChannelsPlaylistsEndpoint): - - @trial_timeout(10) - def test_get_playlists_endpoint_without_channel(self): - """ - Testing whether the API returns error 404 if an unknown channel is queried for playlists - """ - self.should_check_equality = True - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/discovered/aabb/playlists', expected_code=404, expected_json=expected_json) - - @trial_timeout(10) - def test_playlists_endpoint_no_playlists(self): - """ - Testing whether the API returns the right JSON data if no playlists have been added to your channel - """ - channel_cid = 'fakedispersyid'.encode('hex') - self.create_my_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/%s/playlists' % channel_cid, - expected_code=200, expected_json={"playlists": []}) - - @trial_timeout(10) - def test_playlists_endpoint(self): - """ - Testing whether the API returns the right JSON data if playlists are fetched - """ - my_channel_id = self.create_my_channel("my channel", "this is a short description") - channel_cid = 'fakedispersyid'.encode('hex') - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - torrent_list = [ - [my_channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []], - [my_channel_id, 1, 1, ('b' * 40).decode('hex'), 1460000000, "badterm", [['file1.txt', 42]], []] - ] - self.insert_torrents_into_channel(torrent_list) - self.insert_torrent_into_playlist(1234, ('a' * 40).decode('hex')) - self.insert_torrent_into_playlist(1234, ('b' * 40).decode('hex')) - - def verify_playlists(results): - json_result = json.loads(results) - self.assertTrue('playlists' in json_result) - self.assertEqual(len(json_result['playlists']), 1) - self.assertTrue('torrents' in json_result['playlists'][0]) - self.assertEqual(len(json_result['playlists'][0]['torrents']), 1) - torrent = json_result['playlists'][0]['torrents'][0] - self.assertEqual(torrent['infohash'], 'a' * 40) - self.assertEqual(torrent['name'], 'ubuntu-torrent.iso') - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/playlists' % channel_cid, - expected_code=200).addCallback(verify_playlists) - - @trial_timeout(10) - def test_create_playlist_no_channel(self): - """ - Testing whether the API returns error 404 if the channel does not exist when creating a playlist - """ - self.create_my_channel("my channel", "this is a short description") - post_params = {"name": "test1", "description": "test2"} - return self.do_request('channels/discovered/abcd/playlists', expected_code=404, - post_data=post_params, request_type='PUT') - - @trial_timeout(10) - def test_create_playlist_no_name(self): - """ - Testing whether the API returns error 400 if the name is missing when creating a new playlist - """ - self.create_my_channel("my channel", "this is a short description") - expected_json = {"error": "name parameter missing"} - return self.do_request('channels/discovered/%s/playlists' % 'fakedispersyid'.encode('hex'), - expected_code=400, expected_json=expected_json, request_type='PUT') - - @trial_timeout(10) - def test_create_playlist_no_description(self): - """ - Testing whether the API returns error 400 if the description is missing when creating a new playlist - """ - self.create_my_channel("my channel", "this is a short description") - expected_json = {"error": "description parameter missing"} - post_params = {"name": "test"} - return self.do_request('channels/discovered/%s/playlists' % 'fakedispersyid'.encode('hex'), expected_code=400, - expected_json=expected_json, post_data=post_params, request_type='PUT') - - @trial_timeout(10) - def test_create_playlist_no_cmty(self): - """ - Testing whether the API returns error 404 if the the channel community is missing when creating a new playlist - """ - self.create_my_channel("my channel", "this is a short description") - expected_json = {"error": "description parameter missing"} - post_params = {"name": "test1", "description": "test2"} - - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - return self.do_request('channels/discovered/%s/playlists' % 'fakedispersyid'.encode('hex'), expected_code=404, - expected_json=expected_json, post_data=post_params, request_type='PUT') - - @trial_timeout(10) - def test_create_playlist(self): - """ - Testing whether the API can create a new playlist in a given channel - """ - mock_channel_community = MockObject() - mock_channel_community.called_create = False - self.create_fake_channel("channel", "") - - def verify_playlist_created(_): - self.assertTrue(mock_channel_community.called_create) - - def create_playlist_called(name, description, _): - self.assertEqual(name, "test1") - self.assertEqual(description, "test2") - mock_channel_community.called_create = True - - mock_channel_community.create_playlist = create_playlist_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - expected_json = {"created": True} - post_params = {"name": "test1", "description": "test2"} - - return self.do_request('channels/discovered/%s/playlists' % 'fakedispersyid'.encode('hex'), expected_code=200, - expected_json=expected_json, post_data=post_params, request_type='PUT')\ - .addCallback(verify_playlist_created) - - -class TestChannelsModifyPlaylistsEndpoints(AbstractTestChannelsPlaylistsEndpoint): - """ - This class contains tests to verify the modification of playlists. - """ - - @trial_timeout(10) - def test_delete_playlist_no_channel(self): - """ - Testing whether an error 404 is returned when a playlist is removed from a non-existent channel - """ - return self.do_request('channels/discovered/abcd/playlists/1', expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_delete_playlist_no_playlist(self): - """ - Testing whether an error 404 is returned when a non-existent playlist is removed from a channel - """ - channel_cid = 'fakedispersyid'.encode('hex') - self.create_my_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/%s/playlists/1' % channel_cid, - expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_delete_playlist_no_community(self): - """ - Testing whether an error 404 is returned when a playlist is removed from a channel without community - """ - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - channel_cid = 'fakedispersyid'.encode('hex') - my_channel_id = self.create_my_channel("my channel", "this is a short description") - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - return self.do_request('channels/discovered/%s/playlists/1' % channel_cid, - expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_delete_playlist(self): - """ - Testing whether a playlist is correctly removed - """ - mock_channel_community = MockObject() - mock_channel_community.called_remove = False - mock_channel_community.called_remove_torrents = False - my_channel_id = self.create_fake_channel("channel", "") - - def verify_playlist_removed(_): - self.assertTrue(mock_channel_community.called_remove_torrents) - self.assertTrue(mock_channel_community.called_remove) - - def remove_playlist_called(playlists): - self.assertEqual(playlists, [1234]) - mock_channel_community.called_remove = True - - def remove_torrents_called(playlist_id, torrents): - self.assertEqual(playlist_id, 1234) - self.assertEqual(torrents, [42]) - mock_channel_community.called_remove_torrents = True - - mock_channel_community.remove_playlists = remove_playlist_called - mock_channel_community.remove_playlist_torrents = remove_torrents_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - # Create a playlist and add a torrent to it - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - torrent_list = [[my_channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - self.insert_torrent_into_playlist(1234, ('a' * 40).decode('hex')) - - return self.do_request('channels/discovered/%s/playlists/1' % 'fakedispersyid'.encode('hex'), - expected_code=200, expected_json={"removed": True}, - request_type='DELETE').addCallback(verify_playlist_removed) - - @trial_timeout(10) - def test_edit_playlist_no_name(self): - """ - Testing whether an error 400 is returned when a playlist is edit without a name parameter passed - """ - post_params = {'description': 'test'} - expected_json = {'error': 'name parameter missing'} - return self.do_request('channels/discovered/abcd/playlists/1', expected_code=400, - post_data=post_params, request_type='POST', expected_json=expected_json) - - @trial_timeout(10) - def test_edit_playlist_no_description(self): - """ - Testing whether an error 400 is returned when a playlist is edit without a description parameter passed - """ - post_params = {'name': 'test'} - expected_json = {'error': 'description parameter missing'} - return self.do_request('channels/discovered/abcd/playlists/1', expected_code=400, - post_data=post_params, request_type='POST', expected_json=expected_json) - - @trial_timeout(10) - def test_edit_playlist_no_channel(self): - """ - Testing whether an error 404 is returned when a playlist is edit from a non-existent channel - """ - post_params = {'name': 'test', 'description': 'test'} - return self.do_request('channels/discovered/abcd/playlists/1', expected_code=404, - post_data=post_params, request_type='POST') - - @trial_timeout(10) - def test_edit_playlist_no_playlist(self): - """ - Testing whether an error 404 is returned when a non-existent playlist is edited - """ - post_params = {'name': 'test', 'description': 'test'} - channel_cid = 'fakedispersyid'.encode('hex') - self.create_my_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/%s/playlists/1' % channel_cid, - expected_code=404, request_type='POST', post_data=post_params) - - @trial_timeout(10) - def test_edit_playlist_no_community(self): - """ - Testing whether an error 404 is returned when a playlist is edited from a channel without community - """ - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - post_params = {'name': 'test', 'description': 'test'} - channel_cid = 'fakedispersyid'.encode('hex') - my_channel_id = self.create_my_channel("my channel", "this is a short description") - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - return self.do_request('channels/discovered/%s/playlists/1' % channel_cid, - expected_code=404, request_type='POST', post_data=post_params) - - @trial_timeout(10) - def test_edit_playlist(self): - """ - Testing whether a playlist is correctly modified - """ - mock_channel_community = MockObject() - mock_channel_community.called_modify = False - my_channel_id = self.create_fake_channel("channel", "") - - def verify_playlist_modified(_): - self.assertTrue(mock_channel_community.called_modify) - - def modify_playlist_called(playlist_id, modifications): - self.assertEqual(playlist_id, 1) - self.assertEqual(modifications['name'], 'test') - self.assertEqual(modifications['description'], 'test') - mock_channel_community.called_modify = True - - mock_channel_community.modifyPlaylist = modify_playlist_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - - post_params = {'name': 'test', 'description': 'test'} - return self.do_request('channels/discovered/%s/playlists/1' % 'fakedispersyid'.encode('hex'), - expected_code=200, expected_json={"modified": True}, post_data=post_params, - request_type='POST').addCallback(verify_playlist_modified) - - -class TestChannelsModifyPlaylistsAddTorrentEndpoints(AbstractTestChannelsPlaylistsEndpoint): - """ - This class contains tests to verify the addition of torrents to playlists. - """ - @trial_timeout(10) - def test_add_torrent_no_channel(self): - """ - Testing whether an error 404 is returned when a torrent is added to a playlist with a non-existent channel - """ - return self.do_request('channels/discovered/abcd/playlists/1/abcd', expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_no_playlist(self): - """ - Testing whether an error 404 is returned when a torrent is added to a non-existing playlist - """ - mock_channel_community = MockObject() - mock_channel_community.called_add = False - self.create_fake_channel("channel", "") - - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - channel_cid = 'fakedispersyid'.encode('hex') - self.create_my_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/%s/playlists/1/abcd' % channel_cid, - expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_no_community(self): - """ - Testing whether an error 404 is returned when a torrent is added to a playlist without channel community - """ - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - channel_cid = 'fakedispersyid'.encode('hex') - my_channel_id = self.create_my_channel("my channel", "this is a short description") - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - return self.do_request('channels/discovered/%s/playlists/1/abcd' % channel_cid, - expected_code=404, request_type='PUT') - - @trial_timeout(15) - @inlineCallbacks - def test_add_torrent_playlist(self): - """ - Testing whether a torrent can successfully be added to a playlist - """ - mock_channel_community = MockObject() - mock_channel_community.called_add = False - my_channel_id = self.create_fake_channel("channel", "") - - def verify_torrent_added(_): - self.assertTrue(mock_channel_community.called_add) - - def modify_add_called(playlist_id, torrents): - self.assertEqual(playlist_id, 1) - self.assertEqual(torrents, [('a' * 40).decode('hex')]) - mock_channel_community.called_add = True - - mock_channel_community.create_playlist_torrents = modify_add_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - - yield self.do_request('channels/discovered/%s/playlists/1/abcd' % 'fakedispersyid'.encode('hex'), - expected_code=404, request_type='PUT') - - torrent_list = [[my_channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - - yield self.do_request('channels/discovered/%s/playlists/1/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40), - expected_code=200, expected_json={'added': True}, request_type='PUT')\ - .addCallback(verify_torrent_added) - - self.insert_torrent_into_playlist(1234, ('a' * 40).decode('hex')) - - yield self.do_request('channels/discovered/%s/playlists/1/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40), - expected_code=409, request_type='PUT') - - -class TestChannelsModifyPlaylistsRemoveTorrentEndpoints(AbstractTestChannelsPlaylistsEndpoint): - """ - This class contains tests to verify the removal of torrents from playlists. - """ - - @trial_timeout(10) - def test_delete_torrent_no_channel(self): - """ - Testing whether an error 404 is returned when a torrent from a playlist is removed from a non-existent channel - """ - return self.do_request('channels/discovered/abcd/playlists/1/abcd', expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_delete_torrent_no_playlist(self): - """ - Testing whether an error 404 is returned when a torrent from a playlist is removed from a non-existent playlist - """ - channel_cid = 'fakedispersyid'.encode('hex') - self.create_my_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/%s/playlists/1/abcd' % channel_cid, - expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_remove_torrent_no_community(self): - """ - Testing whether an error 404 is returned when a torrent from a playlist without channel community - """ - def mocked_get_community(_): - raise CommunityNotFoundException("abcd") - - mock_dispersy = MockObject() - mock_dispersy.get_community = mocked_get_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - channel_cid = 'fakedispersyid'.encode('hex') - my_channel_id = self.create_my_channel("my channel", "this is a short description") - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - return self.do_request('channels/discovered/%s/playlists/1/abcd' % channel_cid, - expected_code=404, request_type='DELETE') - - @trial_timeout(15) - @inlineCallbacks - def test_remove_torrent_playlist(self): - """ - Testing whether a torrent can be successfully removed from a playlist - """ - mock_channel_community = MockObject() - mock_channel_community.called_remove = False - my_channel_id = self.create_fake_channel("channel", "") - - def verify_torrent_removed(_): - self.assertTrue(mock_channel_community.called_remove) - - def modify_remove_called(playlist_id, torrents): - self.assertEqual(playlist_id, 1) - self.assertEqual(torrents, [42]) - mock_channel_community.called_remove = True - - mock_channel_community.remove_playlist_torrents = modify_remove_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.create_playlist(my_channel_id, 1234, 42, "test playlist", "test description") - - yield self.do_request('channels/discovered/%s/playlists/1/abcd' % 'fakedispersyid'.encode('hex'), - expected_code=404, request_type='DELETE') - - torrent_list = [[my_channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", - [['file1.txt', 42]], []]] - self.insert_torrents_into_channel(torrent_list) - self.insert_torrent_into_playlist(1234, ('a' * 40).decode('hex')) - - yield self.do_request('channels/discovered/%s/playlists/1/%s' % ('fakedispersyid'.encode('hex'), 'a' * 40), - expected_code=200, request_type='DELETE', expected_json={'removed': True})\ - .addCallback(verify_torrent_removed) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_popular_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_popular_endpoint.py deleted file mode 100644 index ca4e039712e..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_popular_endpoint.py +++ /dev/null @@ -1,40 +0,0 @@ -from __future__ import absolute_import -from six.moves import xrange -from twisted.internet.defer import inlineCallbacks - -import Tribler.Core.Utilities.json_util as json -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint -from Tribler.Test.tools import trial_timeout - - -class TestChannelsPlaylistEndpoints(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - @inlineCallbacks - def test_popular_channels_endpoint(self): - """ - Testing whether the API returns some popular channels if the are queried - """ - def verify_channels_limit(results): - json_results = json.loads(results) - self.assertEqual(len(json_results['channels']), 5) - - def verify_channels(results): - json_results = json.loads(results) - self.assertEqual(len(json_results['channels']), 9) - - for i in xrange(0, 9): - self.insert_channel_in_db('rand%d' % i, 42 + i, 'Test channel %d' % i, 'Test description %d' % i) - - self.insert_channel_in_db('badterm1', 200, 'badterm', 'Test description badterm') - self.should_check_equality = False - yield self.do_request('channels/popular?limit=5', expected_code=200).addCallback(verify_channels_limit) - yield self.do_request('channels/popular', expected_code=200).addCallback(verify_channels) - - @trial_timeout(10) - def test_popular_channels_limit_neg(self): - """ - Testing whether error 400 is returned when a negative limit is passed to the request to fetch popular channels - """ - expected_json = {"error": "the limit parameter must be a positive number"} - return self.do_request('channels/popular?limit=-5', expected_code=400, expected_json=expected_json) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_rss_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_rss_endpoint.py deleted file mode 100644 index ba6d9580831..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_rss_endpoint.py +++ /dev/null @@ -1,178 +0,0 @@ -import os - -from twisted.internet.defer import fail -from Tribler.Core.Modules.channel.channel_manager import ChannelManager -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG, \ - UNAUTHORIZED_RESPONSE_MSG -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint -from Tribler.Test.test_as_server import TESTS_DATA_DIR -from Tribler.Test.tools import trial_timeout - - -class TestChannelsRssEndpoints(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_rss_feeds_endpoint_with_channel(self): - """ - Testing whether the API returns the right JSON data if a rss feeds from a channel are fetched - """ - expected_json = {u'rssfeeds': [{u'url': u'http://test1.com/feed.xml'}, {u'url': u'http://test2.com/feed.xml'}]} - channel_name = "my channel" - self.create_fake_channel(channel_name, "this is a short description") - channel_obj = self.session.lm.channel_manager.get_channel(channel_name) - for rss_item in expected_json[u'rssfeeds']: - channel_obj.create_rss_feed(rss_item[u'url']) - - return self.do_request('channels/discovered/%s/rssfeeds' % 'fakedispersyid'.encode('hex'), - expected_code=200, expected_json=expected_json) - - @trial_timeout(10) - def test_add_rss_feed_no_my_channel(self): - """ - Testing whether the API returns a 404 if no channel has been created when adding a rss feed - """ - self.session.lm.channel_manager = ChannelManager(self.session) - channel_cid = 'fakedispersyid'.encode('hex') - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/discovered/' + channel_cid + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', - expected_code=404, expected_json=expected_json, request_type='PUT') - - @trial_timeout(10) - def test_add_rss_feed_conflict(self): - """ - Testing whether the API returns error 409 if a channel the rss feed already exists - """ - expected_json = {"error": "this rss feed already exists"} - my_channel_id = self.create_fake_channel("my channel", "this is a short description") - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - channel_obj.create_rss_feed("http://rssfeed.com/rss.xml") - - return self.do_request('channels/discovered/' + 'fakedispersyid'.encode('hex') + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', expected_code=409, - expected_json=expected_json, request_type='PUT') - - @trial_timeout(10) - def test_add_rss_feed_with_channel(self): - """ - Testing whether the API returns a 200 if a channel has been created and when adding a rss feed - """ - - def verify_rss_added(_): - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - self.assertEqual(channel_obj.get_rss_feed_url_list(), ["http://rssfeed.com/rss.xml"]) - - expected_json = {"added": True} - my_channel_id = self.create_fake_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/' + 'fakedispersyid'.encode('hex') + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', expected_code=200, - expected_json=expected_json, request_type='PUT')\ - .addCallback(verify_rss_added) - - @trial_timeout(10) - def test_remove_rss_feed_no_channel(self): - """ - Testing whether the API returns a 404 if no channel has been removed when adding a rss feed - """ - self.session.lm.channel_manager = ChannelManager(self.session) - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/discovered/' + 'fakedispersyid'.encode('hex') + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', - expected_code=404, expected_json=expected_json, request_type='DELETE') - - @trial_timeout(10) - def test_remove_rss_feed_invalid_url(self): - """ - Testing whether the API returns a 404 and error if the url parameter does not exist in the existing feeds - """ - expected_json = {"error": "this url is not added to your RSS feeds"} - self.create_fake_channel("my channel", "this is a short description") - return self.do_request('channels/discovered/' + 'fakedispersyid'.encode('hex') + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', expected_code=404, - expected_json=expected_json, request_type='DELETE') - - @trial_timeout(10) - def test_remove_rss_feed_with_channel(self): - """ - Testing whether the API returns a 200 if a channel has been created and when removing a rss feed - """ - def verify_rss_removed(_): - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - self.assertEqual(channel_obj.get_rss_feed_url_list(), []) - - expected_json = {"removed": True} - my_channel_id = self.create_fake_channel("my channel", "this is a short description") - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - channel_obj.create_rss_feed("http://rssfeed.com/rss.xml") - - return self.do_request('channels/discovered/' + 'fakedispersyid'.encode('hex') + - '/rssfeeds/http%3A%2F%2Frssfeed.com%2Frss.xml', expected_code=200, - expected_json=expected_json, request_type='DELETE').addCallback(verify_rss_removed) - - @trial_timeout(10) - def test_recheck_rss_feeds_no_channel(self): - """ - Testing whether the API returns a 404 if no channel has been created when rechecking rss feeds - """ - self.session.lm.channel_manager = ChannelManager(self.session) - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/discovered/%s/recheckfeeds' % 'fakedispersyid'.encode('hex'), - expected_code=404, expected_json=expected_json, request_type='POST') - - @trial_timeout(10) - def test_recheck_rss_feeds(self): - """ - Testing whether the API returns a 200 if the rss feeds are rechecked in your channel - """ - expected_json = {"rechecked": True} - my_channel_id = self.create_fake_channel("my channel", "this is a short description") - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - channel_obj._is_created = True - channel_obj.create_rss_feed(os.path.join(TESTS_DATA_DIR, 'test_rss_empty.xml')) - - return self.do_request('channels/discovered/%s/recheckfeeds' % 'fakedispersyid'.encode('hex'), - expected_code=200, expected_json=expected_json, request_type='POST') - - @trial_timeout(10) - def test_recheck_rss_feeds_error(self): - """ - Testing whether the API returns error 500 if refresh of rss feeds is failing - """ - my_channel_id = self.create_fake_channel("my channel", "this is a short description") - channel_obj = self.session.lm.channel_manager.get_my_channel(my_channel_id) - channel_obj._is_created = True - channel_obj.create_rss_feed(os.path.join(TESTS_DATA_DIR, 'test_rss_empty.xml')) - - def mocked_refresh_all_feeds(): - return fail(RuntimeError("test fail")) - - channel_obj.refresh_all_feeds = mocked_refresh_all_feeds - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/recheckfeeds' % 'fakedispersyid'.encode('hex'), - expected_code=500, request_type='POST') - - @trial_timeout(10) - def test_get_rss_feed_no_authorization(self): - """ - Testing whether the API returns unauthorized error if attempting to recheck feeds in another channel - """ - self.channel_db_handler.on_channel_from_dispersy('fake', 3, 'test name', 'test description') - - expected_json = {"error": UNAUTHORIZED_RESPONSE_MSG} - - return self.do_request('channels/discovered/%s/rssfeeds' % 'fake'.encode('hex'), - expected_code=401, expected_json=expected_json, request_type='GET') - - @trial_timeout(10) - def test_get_rss_feed_no_channel_obj(self): - """ - Testing whether the API returns error 404 if no channel object exists in the channel manager - """ - self.create_fake_channel("my channel", "this is a short description") - self.session.lm.channel_manager._channel_list = [] - - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - - return self.do_request('channels/discovered/%s/rssfeeds' % 'fakedispersyid'.encode('hex'), - expected_code=404, expected_json=expected_json, request_type='GET') diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py deleted file mode 100644 index 62fe04421d2..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_subscription_endpoint.py +++ /dev/null @@ -1,218 +0,0 @@ -from __future__ import absolute_import - -import time -from binascii import hexlify - -import six -from pony.orm import db_session -from six.moves import xrange -from twisted.internet.defer import succeed, fail, inlineCallbacks -from twisted.python.failure import Failure - -from Tribler.Core.Modules.restapi import VOTE_UNSUBSCRIBE -from Tribler.Core.Modules.restapi.channels.base_channels_endpoint import UNKNOWN_CHANNEL_RESPONSE_MSG -from Tribler.Core.Modules.restapi.channels.channels_subscription_endpoint import NOT_SUBSCRIBED_RESPONSE_MSG, \ - ChannelsModifySubscriptionEndpoint -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ - AbstractTestChantEndpoint -from Tribler.Test.tools import trial_timeout - - -class TestChannelsSubscriptionEndpoint(AbstractTestChannelsEndpoint): - - @inlineCallbacks - def setUp(self): - """ - The startup method of this class creates a fake Dispersy instance with a fake AllChannel community. It also - inserts some random channels so we have some data to work with. - """ - yield super(TestChannelsSubscriptionEndpoint, self).setUp() - self.expected_votecast_cid = None - self.expected_votecast_vote = None - self.create_votecast_called = False - - fake_community = self.create_fake_allchannel_community() - fake_community.disp_create_votecast = self.on_dispersy_create_votecast - self.session.config.get_dispersy_enabled = lambda: True - self.session.lm.dispersy.attach_community(fake_community) - for i in xrange(0, 10): - self.insert_channel_in_db('rand%d' % i, 42 + i, 'Test channel %d' % i, 'Test description %d' % i) - - def on_dispersy_create_votecast(self, cid, vote, _): - """ - Check whether we have the expected parameters when this method is called. - """ - self.assertEqual(cid, self.expected_votecast_cid) - self.assertEqual(vote, self.expected_votecast_vote) - self.create_votecast_called = True - return succeed(None) - - @trial_timeout(10) - def test_sub_channel_throw_error(self): - """ - Testing whether an error is returned when we subscribe to a channel and an error pops up - """ - - def mocked_vote(*_): - return fail(Failure(RuntimeError("error"))) - - mod_sub_endpoint = ChannelsModifySubscriptionEndpoint(self.session, '') - mod_sub_endpoint.vote_for_channel = mocked_vote - subscribed_endpoint = self.session.lm.api_manager.root_endpoint.children['channels'].children["subscribed"] - subscribed_endpoint.getChild = lambda *_: mod_sub_endpoint - - self.should_check_equality = False - return self.do_request('channels/subscribed/', expected_code=500, request_type='PUT') - - @trial_timeout(10) - def test_unsubscribe_channel_not_exist(self): - """ - Testing whether the API returns an error when unsubscribing if the channel with the specified CID does not exist - """ - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/subscribed/abcdef', expected_code=404, expected_json=expected_json, - request_type='DELETE') - - @trial_timeout(10) - def test_unsubscribe_channel_not_subscribed(self): - """ - Testing whether the API returns error 404 when unsubscribing from an already unsubscribed channel - """ - expected_json = {"error": NOT_SUBSCRIBED_RESPONSE_MSG} - self.insert_channel_in_db('rand1', 42, 'Test channel', 'Test description') - return self.do_request('channels/subscribed/%s' % hexlify(b'rand1'), - expected_code=404, expected_json=expected_json, request_type='DELETE') - - @trial_timeout(10) - def test_get_subscribed_channels_no_subscriptions(self): - """ - Testing whether the API returns no channels when you have not subscribed to any channel - """ - expected_json = {"subscribed": []} - return self.do_request('channels/subscribed', expected_code=200, expected_json=expected_json) - - @trial_timeout(10) - def test_get_subscribed_channels_one_subscription(self): - """ - Testing whether the API returns the right channel when subscribed to one channel - """ - expected_json = {u'subscribed': [{u'description': u'This is a description', u'id': -1, - u'dispersy_cid': six.text_type(hexlify(b'rand')), - u'modified': int(time.time()), - u'name': u'Test Channel', u'spam': 0, - u'subscribed': True, u'torrents': 0, u'votes': 0}]} - - cid = self.insert_channel_in_db('rand', 42, expected_json[u'subscribed'][0][u'name'], - expected_json[u'subscribed'][0][u'description']) - expected_json[u'subscribed'][0][u'id'] = cid - self.vote_for_channel(cid, expected_json[u'subscribed'][0][u'modified']) - return self.do_request('channels/subscribed', expected_code=200, expected_json=expected_json) - - @trial_timeout(10) - def test_unsubscribe_channel(self): - """ - Testing whether the API creates a request in the AllChannel community when unsubscribing from a channel - """ - - def verify_votecast_made(_): - self.assertTrue(self.create_votecast_called) - - cid = self.insert_channel_in_db('rand1', 42, 'Test channel', 'Test description') - self.vote_for_channel(cid, int(time.time())) - - expected_json = {"unsubscribed": True} - self.expected_votecast_cid = 'rand1' - self.expected_votecast_vote = VOTE_UNSUBSCRIBE - return self.do_request('channels/subscribed/%s' % hexlify(b'rand1'), expected_code=200, - expected_json=expected_json, request_type='DELETE').addCallback(verify_votecast_made) - - @trial_timeout(10) - def test_is_channel_subscribed(self): - """ - Testing the subscription status of channel - """ - cid = self.insert_channel_in_db('rand1', 42, 'Test channel', 'Test description') - self.vote_for_channel(cid, int(time.time())) - - expected_json = {"subscribed": True, "votes": 0} # here votes represent previous dispersy votes which is zero - return self.do_request('channels/subscribed/%s' % hexlify(b'rand1'), expected_code=200, - expected_json=expected_json, request_type='GET') - - @trial_timeout(10) - def test_subscribed_status_of_non_existing_channel(self): - """ - Testing the subscription status of non-existing channel - """ - expected_json = {"error": UNKNOWN_CHANNEL_RESPONSE_MSG} - return self.do_request('channels/subscribed/deadbeef', expected_code=404, expected_json=expected_json, - request_type='GET') - - -class TestChannelsSubscriptionChantEndpoint(AbstractTestChantEndpoint): - - @trial_timeout(10) - def test_subscribe(self): - """ - Test subscribing to a (random) chant channel with the API - """ - random_channel = self.add_random_channel() - random_channel_id = hexlify(random_channel.public_key) - - def verify_response(_): - updated_channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(random_channel.public_key) - self.assertTrue(updated_channel.subscribed) - - self.should_check_equality = False - return self.do_request('channels/subscribed/%s' % random_channel_id, expected_code=200, request_type='PUT') \ - .addCallback(verify_response) - - @trial_timeout(10) - def test_subscribe_twice(self): - """ - Test whether an error is raised when subscribing to a channel we are already subscribed to - """ - with db_session: - random_channel = self.add_random_channel() - random_channel.subscribed = True - random_channel_id = hexlify(random_channel.public_key) - - self.should_check_equality = False - return self.do_request('channels/subscribed/%s' % random_channel_id, expected_code=409, request_type='PUT') - - @trial_timeout(10) - def test_subscribe_unknown_channel(self): - """ - Test whether an error is raised when subscribing to an unknown channel - """ - self.should_check_equality = False - return self.do_request('channels/subscribed/aaaa', expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_get_subscribed_channels_no_subscriptions(self): - """ - Testing whether the API returns no channels when you have not subscribed to any channel - """ - expected_json = {"subscribed": []} - return self.do_request('channels/subscribed', expected_code=200, expected_json=expected_json) - - @trial_timeout(10) - def test_get_subscribed_channels_one_subscription(self): - """ - Testing whether the API returns the right channel when subscribed to one channel - """ - with db_session: - md = self.session.lm.mds.ChannelMetadata(title="Test channel", subscribed=True) - title = md.title - cid = hexlify(md.public_key) - version = md.version - subscribed = md.subscribed - torrents = md.size - votes = md.votes - tags = md.tags - expected_json = {u'subscribed': [{u'description': six.text_type(tags), u'id': 0, - u'dispersy_cid': six.text_type(cid), - u'modified': version, - u'name': six.text_type(title), u'spam': 0, - u'subscribed': subscribed, u'torrents': torrents, u'votes': votes}]} - - return self.do_request('channels/subscribed', expected_code=200, expected_json=expected_json) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py deleted file mode 100644 index b8cb46da8c9..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_channels_torrents_endpoint.py +++ /dev/null @@ -1,418 +0,0 @@ -from __future__ import absolute_import - -import base64 -import os -import shutil -import urllib - -from pony.orm import db_session -from twisted.internet.defer import inlineCallbacks - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.exceptions import HttpError -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChannelsEndpoint, \ - AbstractTestChantEndpoint -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.common import TORRENT_UBUNTU_FILE -from Tribler.Test.tools import trial_timeout - - -class TestChannelTorrentsEndpoint(AbstractTestChannelsEndpoint): - - @trial_timeout(10) - def test_add_torrent_to_channel(self): - """ - Testing whether adding a torrent to your channels works - """ - my_channel_id = self.create_fake_channel("channel", "") - torrent_path = TORRENT_UBUNTU_FILE - - def verify_method_invocation(channel_id, torrent_def, extra_info=None, forward=True): - self.assertEqual(my_channel_id, channel_id) - self.assertEqual(TorrentDef.load(torrent_path), torrent_def) - self.assertEqual({}, extra_info or {}) - self.assertEqual(True, forward) - - self.session.add_torrent_def_to_channel = verify_method_invocation - - with open(torrent_path, mode='rb') as torrent_file: - torrent_64 = base64.b64encode(torrent_file.read()) - - post_data = { - "torrent": torrent_64 - } - expected_json = {"added": True} - return self.do_request('channels/discovered/%s/torrents' % 'fakedispersyid'.encode('hex'), 200, - expected_json, 'PUT', post_data) - - @trial_timeout(10) - def test_add_torrent_to_channel_with_description(self): - """ - Testing whether adding a torrent with a description to a channel works - """ - my_channel_id = self.create_fake_channel("channel", "") - torrent_path = TORRENT_UBUNTU_FILE - - def verify_method_invocation(channel_id, torrent_def, extra_info=None, forward=True): - self.assertEqual(my_channel_id, channel_id) - self.assertEqual(TorrentDef.load(torrent_path), torrent_def) - self.assertEqual({"description": "video of my cat"}, extra_info or {}) - self.assertEqual(True, forward) - - self.session.add_torrent_def_to_channel = verify_method_invocation - - with open(torrent_path, mode='rb') as torrent_file: - torrent_64 = base64.b64encode(torrent_file.read()) - - post_data = { - "torrent": torrent_64, - "description": "video of my cat" - } - expected_json = {"added": True} - return self.do_request('channels/discovered/%s/torrents' % 'fakedispersyid'.encode('hex'), - 200, expected_json, 'PUT', post_data) - - @trial_timeout(10) - def test_add_torrent_to_channel_404(self): - """ - Testing whether adding a torrent to a non-existing channel returns error 404 - """ - return self.do_request('channels/discovered/%s/torrents' % 'fakedispersyid'.encode('hex'), - expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_to_channel_missing_parameter(self): - """ - Testing whether error 400 is returned when the torrent parameter is missing when adding a torrent to a channel - """ - self.create_fake_channel("channel", "") - expected_json = {"error": "torrent parameter missing"} - return self.do_request('channels/discovered/%s/torrents' % 'fakedispersyid'.encode('hex'), 400, - expected_json, 'PUT') - - @trial_timeout(10) - def test_add_torrent_to_channel_500(self): - """ - Testing whether the API returns a formatted 500 error if ValueError is raised - """ - self.create_fake_channel("channel", "") - torrent_path = TORRENT_UBUNTU_FILE - - def fake_error(channel_id, torrent_def, extra_info=None, forward=True): - raise HttpError(msg="Test error") - - self.session.add_torrent_def_to_channel = fake_error - - def verify_error_message(body): - error_response = json.loads(body) - expected_response = { - u"error": { - u"handled": True, - u"code": u"HttpError", - u"message": u"Test error" - } - } - self.assertDictContainsSubset(expected_response[u"error"], error_response[u"error"]) - - with open(torrent_path, mode='rb') as torrent_file: - torrent_64 = base64.b64encode(torrent_file.read()) - - post_data = { - "torrent": torrent_64 - } - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents' % 'fakedispersyid'.encode('hex'), - expected_code=500, expected_json=None, request_type='PUT', - post_data=post_data).addCallback(verify_error_message) - - -class TestModifyChannelTorrentEndpoint(AbstractTestChannelsEndpoint): - - @inlineCallbacks - def setUp(self): - yield super(TestModifyChannelTorrentEndpoint, self).setUp() - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.shutdown = lambda: True - - @trial_timeout(10) - def test_add_torrent_from_url_to_channel_with_description(self): - """ - Testing whether a torrent can be added to a channel using the API - """ - my_channel_id = self.create_fake_channel("channel", "") - - # Setup file server to serve torrent file - files_path = os.path.join(self.session_base_dir, 'http_torrent_files') - os.mkdir(files_path) - shutil.copyfile(TORRENT_UBUNTU_FILE, os.path.join(files_path, 'ubuntu.torrent')) - file_server_port = get_random_port() - self.setUpFileServer(file_server_port, files_path) - - def verify_method_invocation(channel_id, torrent_def, extra_info=None, forward=True): - self.assertEqual(my_channel_id, channel_id) - self.assertEqual(TorrentDef.load(TORRENT_UBUNTU_FILE), torrent_def) - self.assertEqual({"description": "test"}, extra_info or {}) - self.assertEqual(True, forward) - - self.session.add_torrent_def_to_channel = verify_method_invocation - - torrent_url = 'http://localhost:%d/ubuntu.torrent' % file_server_port - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(torrent_url)) - return self.do_request(url, expected_code=200, expected_json={"added": torrent_url}, request_type='PUT', - post_data={"description": "test"}) - - @trial_timeout(10) - def test_add_torrent_from_magnet_to_channel(self): - """ - Testing whether adding a torrent with a magnet link to a channel without description works - """ - my_channel_id = self.create_fake_channel("channel", "") - - def fake_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): - meta_info = TorrentDef.load(TORRENT_UBUNTU_FILE).get_metainfo() - callback(meta_info) - - self.session.lm.ltmgr.get_metainfo = fake_get_metainfo - - def verify_method_invocation(channel_id, torrent_def, extra_info=None, forward=True): - self.assertEqual(my_channel_id, channel_id) - self.assertEqual(TorrentDef.load(TORRENT_UBUNTU_FILE), torrent_def) - self.assertEqual({}, extra_info or {}) - self.assertEqual(True, forward) - - self.session.add_torrent_def_to_channel = verify_method_invocation - - magnet_url = 'magnet:?fake' - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(magnet_url)) - return self.do_request(url, expected_code=200, expected_json={"added": magnet_url}, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_to_channel_404(self): - """ - Testing whether adding a torrent to a non-existing channel returns error code 404 - """ - self.should_check_equality = False - return self.do_request('channels/discovered/abcd/torrents/fake_url', - expected_code=404, expected_json=None, request_type='PUT') - - @trial_timeout(10) - def test_add_magnet_to_channel_500(self): - """ - Testing whether the API returns a formatted 500 error if ValueError is raised - """ - self.create_fake_channel("channel", "") - - def fake_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): - raise ValueError(u"Test error") - - self.session.lm.ltmgr.get_metainfo = fake_get_metainfo - - def verify_error_message(body): - error_response = json.loads(body) - expected_response = { - u"error": { - u"handled": True, - u"code": u"ValueError", - u"message": u"Test error" - } - } - self.assertDictContainsSubset(expected_response[u"error"], error_response[u"error"]) - - torrent_url = 'magnet:fake' - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(torrent_url)) - self.should_check_equality = False - return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT') \ - .addCallback(verify_error_message) - - @trial_timeout(10) - def test_timeout_on_add_torrent(self): - """ - Testing timeout in adding torrent - """ - self.create_fake_channel("channel", "") - - def on_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): - timeout_callback("infohash_whatever") - - self.session.lm.ltmgr.get_metainfo = on_get_metainfo - - def verify_error_message(body): - error_response = json.loads(body) - expected_response = { - u"error": { - u"handled": True, - u"code": u"RuntimeError", - u"message": u"Metainfo timeout" - } - } - self.assertDictContainsSubset(expected_response[u"error"], error_response[u"error"]) - - torrent_url = 'magnet:fake' - url = 'channels/discovered/%s/torrents/%s' % ('fakedispersyid'.encode('hex'), urllib.quote_plus(torrent_url)) - self.should_check_equality = False - return self.do_request(url, expected_code=500, expected_json=None, request_type='PUT') \ - .addCallback(verify_error_message) - - -class TestChannelTorrentsChantEndpoint(AbstractTestChantEndpoint): - - @trial_timeout(10) - def test_add_torrent_to_external_channel(self): - """ - Test whether adding a torrent to a channel that you do not own, results in an error - """ - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents' % ('a' * (74 * 2)), - expected_code=405, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_to_non_existing_channel(self): - """ - Test whether adding a torrent to your non-existent channel results in an error - """ - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents' % my_channel_id.encode('hex'), - expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_add_torrent_to_channel(self): - """ - Test adding a torrent to a chant channel using the API - """ - my_channel = self.create_my_channel('test', 'test') - with open(TORRENT_UBUNTU_FILE, mode='rb') as torrent_file: - torrent_64 = base64.b64encode(torrent_file.read()) - - def verify_added(_): - updated_my_channel = self.get_my_channel() - with db_session: - self.assertEqual(len(updated_my_channel.contents_list), 1) - - self.should_check_equality = False - post_data = {'torrent': torrent_64, 'description': 'description'} - return self.do_request('channels/discovered/%s/torrents' % str(my_channel.public_key).encode('hex'), - expected_code=200, request_type='PUT', post_data=post_data).addCallback(verify_added) - - @trial_timeout(10) - @db_session - def test_add_torrent_to_channel_twice(self): - """ - Test whether adding a torrent to a chant channel twice results in an error - """ - my_channel = self.create_my_channel('test', 'test') - tdef = TorrentDef.load(TORRENT_UBUNTU_FILE) - my_channel.add_torrent_to_channel(tdef, None) - - with open(TORRENT_UBUNTU_FILE, mode='rb') as torrent_file: - torrent_64 = base64.b64encode(torrent_file.read()) - - self.should_check_equality = False - post_data = {'torrent': torrent_64, 'description': 'description'} - return self.do_request('channels/discovered/%s/torrents' % str(my_channel.public_key).encode('hex'), - expected_code=500, request_type='PUT', post_data=post_data) - - @trial_timeout(10) - def test_add_invalid_torrent_to_channel(self): - my_channel = self.create_my_channel('test', 'test') - self.should_check_equality = False - post_data = {'torrent': base64.b64encode('test'), 'description': 'description'} - return self.do_request('channels/discovered/%s/torrents' % str(my_channel.public_key).encode('hex'), - expected_code=500, request_type='PUT', post_data=post_data) - - -class TestModifyChantChannelTorrentEndpoint(AbstractTestChantEndpoint): - - @trial_timeout(10) - def test_add_magnet_to_external_channel(self): - """ - Test whether adding a magnet URL to a channel that you do not own, results in an error - """ - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/fake_url' % ('a' * (74 * 2)), - expected_code=405, request_type='PUT') - - @trial_timeout(10) - def test_add_magnet_to_non_existing_channel(self): - """ - Test whether adding a magnet URL to your non-existent channel results in an error - """ - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/fake_url' % my_channel_id.encode('hex'), - expected_code=404, request_type='PUT') - - @trial_timeout(10) - def test_add_magnet_to_channel(self): - """ - Test adding a magnet to a chant channel using the API - """ - - def fake_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): - meta_info = TorrentDef.load(TORRENT_UBUNTU_FILE).get_metainfo() - callback(meta_info) - - self.session.lm.ltmgr.get_metainfo = fake_get_metainfo - my_channel = self.create_my_channel('test', 'test') - - def verify_added(_): - updated_my_channel = self.get_my_channel() - with db_session: - self.assertEqual(len(updated_my_channel.contents_list), 1) - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/magnet:?fake' % - str(my_channel.public_key).encode('hex'), - expected_code=200, request_type='PUT').addCallback(verify_added) - - @trial_timeout(10) - def test_remove_torrent_from_external_channel(self): - """ - Test whether removing a torrent from a channel that you do not own, results in an error - """ - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/%s' % ('a' * (74 * 2), 'a' * 40), - expected_code=405, request_type='DELETE') - - @trial_timeout(10) - def test_remove_torrent_from_unknown_channel(self): - """ - Test whether removing a torrent from your (non-existent) channel results in an error - """ - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/%s' % (my_channel_id.encode('hex'), 'a' * 40), - expected_code=404, request_type='DELETE') - - @trial_timeout(10) - def test_remove_single_torrent_from_my_channel(self): - """ - Test whether we can remove a torrent from your channel using the API - """ - with db_session: - my_channel = self.create_my_channel('test', 'test123') - random_torrent = self.add_random_torrent_to_my_channel(name='bla') - my_channel.commit_channel_torrent() - - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/%s' % - (str(my_channel.public_key).encode('hex'), str(random_torrent.infohash).encode('hex')), - expected_code=200, request_type='DELETE') - - @trial_timeout(10) - def test_remove_multiple_torrents_from_my_channel_fail(self): - """ - Test removing some torrents from your channel with the API, while that fails - """ - - def verify_response(response): - json_response = json.loads(response) - self.assertIn('failed_torrents', json_response) - - my_channel = self.create_my_channel('test', 'test123') - self.should_check_equality = False - return self.do_request('channels/discovered/%s/torrents/%s' % - (str(my_channel.public_key).encode('hex'), 'aa'), - expected_code=200, request_type='DELETE').addCallback(verify_response) diff --git a/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py b/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py deleted file mode 100644 index 3b6aade9b32..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/Channels/test_my_channel_endpoints.py +++ /dev/null @@ -1,97 +0,0 @@ -from __future__ import absolute_import - -from binascii import hexlify - -import six -from pony.orm import db_session -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Modules.restapi.channels.my_channel_endpoint import NO_CHANNEL_CREATED_RESPONSE_MSG -from Tribler.Test.Core.Modules.RestApi.Channels.test_channels_endpoint import AbstractTestChantEndpoint -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.tools import trial_timeout - - -class TestMyChannelChantEndpoints(AbstractTestChantEndpoint): - - @inlineCallbacks - def test_edit_channel(self): - """ - Testing whether a channel is correctly modified - """ - self.create_my_channel('test', 'test') - mock_channel_community = MockObject() - mock_channel_community.called_modify = False - - def verify_channel_modified(_): - self.assertTrue(mock_channel_community.called_modify) - - def modify_channel_called(modifications): - self.assertEqual(modifications['name'], 'test1') - self.assertEqual(modifications['description'], 'test2') - mock_channel_community.called_modify = True - - mock_channel_community.modifyChannel = modify_channel_called - mock_dispersy = MockObject() - mock_dispersy.get_community = lambda _: mock_channel_community - self.session.get_dispersy_instance = lambda: mock_dispersy - - self.should_check_equality = False - post_params = {'name': '', 'description': 'test2'} - yield self.do_request('mychannel', expected_code=400, post_data=post_params, request_type='POST') - - self.should_check_equality = True - post_params = {'name': 'test1', 'description': 'test2'} - yield self.do_request('mychannel', expected_code=200, expected_json={"modified": True}, post_data=post_params, - request_type='POST').addCallback(verify_channel_modified) - - @trial_timeout(10) - def test_my_channel_overview_endpoint_no_my_channel(self): - """ - Testing whether the API returns response code 404 if no chant channel has been created - """ - expected_json = {"error": NO_CHANNEL_CREATED_RESPONSE_MSG} - return self.do_request('mychannel', expected_json=expected_json, expected_code=404) - - @trial_timeout(10) - def test_my_channel_overview_endpoint_with_channel(self): - """ - Testing whether the API returns the right JSON data if an existing chant channel overview is requested - """ - channel_json = {u'mychannel': {u'name': u'testname', u'description': u'testdescription', - u'identifier': six.text_type( - hexlify(self.session.trustchain_keypair.pub().key_to_bin()))}} - self.create_my_channel(channel_json[u'mychannel'][u'name'], channel_json[u'mychannel'][u'description']) - - return self.do_request('mychannel', expected_code=200, expected_json=channel_json) - - @trial_timeout(10) - def test_edit_channel_not_exist(self): - """ - Test whether the API returns error 404 when trying to edit a non-existing channel - """ - post_params = {'name': 'new channel', 'description': 'new description'} - self.should_check_equality = False - return self.do_request('mychannel', request_type='POST', post_data=post_params, expected_code=404) - - @trial_timeout(10) - def test_edit_channel(self): - """ - Test editing your chant channel - """ - self.create_my_channel('my channel', 'fancy description') - self.add_random_torrent_to_my_channel() - post_params = {'name': 'new channel', 'description': 'new description', 'commit_changes': 1} - - @db_session - def verify_response(_): - my_channel = self.get_my_channel() - self.assertEqual(my_channel.title, 'new channel') - self.assertEqual(my_channel.tags, 'new description') - self.assertEqual(len(my_channel.contents_list), 1) - self.assertEqual(len(my_channel.staged_entries_list), 0) - - channel_json = {'modified': 1} - self.should_check_equality = False - return self.do_request('mychannel', request_type='POST', post_data=post_params, expected_json=channel_json, - expected_code=200).addCallback(verify_response) diff --git a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py index d95492f81da..6ea8d2d3e91 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py @@ -1,6 +1,7 @@ +from __future__ import absolute_import + import logging -from Tribler.Test.tools import trial_timeout from twisted.internet import reactor from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.protocol import Protocol @@ -8,15 +9,15 @@ from twisted.web.client import Agent, HTTPConnectionPool from twisted.web.http_headers import Headers -from Tribler.Core.simpledefs import SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, SIGNAL_TORRENT, NTFY_UPGRADER, \ - NTFY_STARTED, NTFY_FINISHED, NTFY_UPGRADER_TICK, NTFY_WATCH_FOLDER_CORRUPT_TORRENT, NTFY_INSERT, NTFY_NEW_VERSION, \ - NTFY_CHANNEL, NTFY_DISCOVERED, NTFY_TORRENT, NTFY_ERROR, NTFY_DELETE, NTFY_MARKET_ON_ASK, NTFY_UPDATE, \ - NTFY_MARKET_ON_BID, NTFY_MARKET_ON_ASK_TIMEOUT, NTFY_MARKET_ON_BID_TIMEOUT, NTFY_MARKET_ON_TRANSACTION_COMPLETE, \ - NTFY_MARKET_ON_PAYMENT_RECEIVED, NTFY_MARKET_ON_PAYMENT_SENT, SIGNAL_RESOURCE_CHECK, SIGNAL_LOW_SPACE, \ - NTFY_CREDIT_MINING import Tribler.Core.Utilities.json_util as json +from Tribler.Core.simpledefs import NTFY_CHANNEL, NTFY_CREDIT_MINING, NTFY_DISCOVERED, NTFY_ERROR, NTFY_FINISHED, \ + NTFY_INSERT, NTFY_MARKET_ON_ASK, NTFY_MARKET_ON_ASK_TIMEOUT, NTFY_MARKET_ON_BID, NTFY_MARKET_ON_BID_TIMEOUT, \ + NTFY_MARKET_ON_PAYMENT_RECEIVED, NTFY_MARKET_ON_PAYMENT_SENT, NTFY_MARKET_ON_TRANSACTION_COMPLETE, \ + NTFY_NEW_VERSION, NTFY_STARTED, NTFY_TORRENT, NTFY_UPDATE, NTFY_UPGRADER, NTFY_UPGRADER_TICK, \ + NTFY_WATCH_FOLDER_CORRUPT_TORRENT, SIGNAL_LOW_SPACE, SIGNAL_RESOURCE_CHECK from Tribler.Core.version import version_id from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.tools import trial_timeout class EventDataProtocol(Protocol): diff --git a/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py index ca55ba60fc4..a4c7cda11c7 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py @@ -1,5 +1,9 @@ +from __future__ import absolute_import + import json +from twisted.internet.defer import inlineCallbacks, succeed + from Tribler.community.market.community import MarketCommunity from Tribler.community.market.core.assetamount import AssetAmount from Tribler.community.market.core.assetpair import AssetPair @@ -17,7 +21,6 @@ from Tribler.Test.Core.base_test import MockObject from Tribler.Test.tools import trial_timeout from Tribler.pyipv8.ipv8.test.mocking.ipv8 import MockIPv8 -from twisted.internet.defer import inlineCallbacks, succeed class TestMarketEndpoint(AbstractApiTest): diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py new file mode 100644 index 00000000000..1d3da8887f4 --- /dev/null +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -0,0 +1,284 @@ +import json +from binascii import hexlify + +import six +from six.moves import xrange + +from pony.orm import db_session +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.TorrentChecker.torrent_checker import TorrentChecker +from Tribler.Core.Utilities.network_utils import get_random_port +from Tribler.Core.Utilities.random_utils import random_infohash +from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.tools import trial_timeout +from Tribler.Test.util.Tracker.HTTPTracker import HTTPTracker +from Tribler.Test.util.Tracker.UDPTracker import UDPTracker +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + + +class BaseTestMetadataEndpoint(AbstractApiTest): + + @inlineCallbacks + def setUp(self): + yield super(BaseTestMetadataEndpoint, self).setUp() + + # Add a few channels + with db_session: + for ind in xrange(10): + self.session.lm.mds.Metadata._my_key = default_eccrypto.generate_key('low') + _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) + for torrent_ind in xrange(5): + _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=random_infohash()) + + def setUpPreSession(self): + super(BaseTestMetadataEndpoint, self).setUpPreSession() + self.config.set_chant_enabled(True) + + +class TestChannelsEndpoint(BaseTestMetadataEndpoint): + + def test_get_channels(self): + """ + Test whether we can query some channels in the database with the REST API + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['channels']), 10) + + self.should_check_equality = False + return self.do_request('metadata/channels?sort_by=title', expected_code=200).addCallback(on_response) + + def test_get_channels_invalid_sort(self): + """ + Test whether we can query some channels in the database with the REST API and an invalid sort parameter + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['channels']), 10) + + self.should_check_equality = False + return self.do_request('metadata/channels?sort_by=fdsafsdf', expected_code=200).addCallback(on_response) + + def test_get_subscribed_channels(self): + """ + Test whether we can successfully query channels we are subscribed to with the REST API + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['channels']), 5) + + self.should_check_equality = False + return self.do_request('metadata/channels?subscribed=1', expected_code=200).addCallback(on_response) + + +class TestSpecificChannelEndpoint(BaseTestMetadataEndpoint): + + def test_subscribe_missing_parameter(self): + """ + Test whether an error is returned if we try to subscribe to a channel with the REST API and missing parameters + """ + self.should_check_equality = False + channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) + return self.do_request('metadata/channels/%s' % channel_pk, expected_code=400, request_type='POST') + + def test_subscribe_no_channel(self): + """ + Test whether an error is returned if we try to subscribe to a channel with the REST API and a missing channel + """ + self.should_check_equality = False + post_params = {'subscribe': '1'} + return self.do_request('metadata/channels/aa', expected_code=404, request_type='POST', post_data=post_params) + + def test_subscribe(self): + """ + Test whether we can subscribe to a channel with the REST API + """ + self.should_check_equality = False + post_params = {'subscribe': '1'} + channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()) + return self.do_request('metadata/channels/%s' % channel_pk, expected_code=200, + request_type='POST', post_data=post_params) + + +class TestSpecificChannelTorrentsEndpoint(BaseTestMetadataEndpoint): + + def test_get_torrents(self): + """ + Test whether we can query some torrents in the database with the REST API + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['torrents']), 5) + + self.should_check_equality = False + channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()) + return self.do_request('metadata/channels/%s/torrents' % channel_pk, expected_code=200).addCallback(on_response) + + +class TestPopularChannelsEndpoint(BaseTestMetadataEndpoint): + + def test_get_popular_channels_neg_limit(self): + """ + Test whether an error is returned if we use a negative value for the limit parameter + """ + self.should_check_equality = False + return self.do_request('metadata/channels/popular?limit=-1', expected_code=400) + + def test_get_popular_channels(self): + """ + Test whether we can retrieve popular channels with the REST API + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['channels']), 5) + + self.should_check_equality = False + return self.do_request('metadata/channels/popular?limit=5', expected_code=200).addCallback(on_response) + + +class TestRandomTorrentsEndpoint(BaseTestMetadataEndpoint): + + def test_get_random_torrents_neg_limit(self): + """ + Test if an error is returned if we query some random torrents with the REST API and a negative limit + """ + self.should_check_equality = False + return self.do_request('metadata/torrents/random?limit=-5', expected_code=400) + + def test_get_random_torrents(self): + """ + Test whether we can retrieve some random torrents with the REST API + """ + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['torrents']), 5) + + self.should_check_equality = False + return self.do_request('metadata/torrents/random?limit=5', expected_code=200).addCallback(on_response) + + +class TestTorrentHealthEndpoint(AbstractApiTest): + + def setUpPreSession(self): + super(TestTorrentHealthEndpoint, self).setUpPreSession() + self.config.set_chant_enabled(True) + + @inlineCallbacks + def setUp(self): + yield super(TestTorrentHealthEndpoint, self).setUp() + + min_base_port, max_base_port = self.get_bucket_range_port() + + self.udp_port = get_random_port(min_port=min_base_port, max_port=max_base_port) + self.udp_tracker = UDPTracker(self.udp_port) + + self.http_port = get_random_port(min_port=min_base_port, max_port=max_base_port) + self.http_tracker = HTTPTracker(self.http_port) + + @inlineCallbacks + def tearDown(self): + self.session.lm.ltmgr = None + if self.udp_tracker: + yield self.udp_tracker.stop() + if self.http_tracker: + yield self.http_tracker.stop() + yield super(TestTorrentHealthEndpoint, self).tearDown() + + @trial_timeout(20) + @inlineCallbacks + def test_check_torrent_health(self): + """ + Test the endpoint to fetch the health of a torrent + """ + torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) + torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu-torrent.iso', [['file1.txt', 42]], + ('udp://localhost:%s/announce' % self.udp_port, + 'http://localhost:%s/announce' % self.http_port), time.time()) + + url = 'metadata/torrents/%s/health?timeout=10&refresh=1' % hexlify(b'a' * 20) + + self.should_check_equality = False + yield self.do_request(url, expected_code=400, request_type='GET') # No torrent checker + + def call_cb(infohash, callback, **_): + callback({"seeders": 1, "leechers": 2}) + + # Initialize the torrent checker + self.session.lm.torrent_checker = TorrentChecker(self.session) + self.session.lm.torrent_checker.initialize() + self.session.lm.ltmgr = MockObject() + self.session.lm.ltmgr.get_metainfo = call_cb + + yield self.do_request('torrents/%s/health' % ('f' * 40), expected_code=404, request_type='GET') + + def verify_response_no_trackers(response): + json_response = json.loads(response) + self.assertTrue('DHT' in json_response['health']) + + def verify_response_with_trackers(response): + hex_as = hexlify(b'a' * 20) + json_response = json.loads(response) + expected_dict = {u"health": + {u"DHT": + {u"leechers": 2, u"seeders": 1, u"infohash": hex_as}, + u"udp://localhost:%s" % self.udp_port: + {u"leechers": 20, u"seeders": 10, u"infohash": hex_as}, + u"http://localhost:%s/announce" % self.http_port: + {u"leechers": 30, u"seeders": 20, u"infohash": hex_as}}} + self.assertDictEqual(json_response, expected_dict) + + yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) + + self.udp_tracker.start() + self.udp_tracker.tracker_info.add_info_about_infohash('a' * 20, 10, 20) + + self.http_tracker.start() + self.http_tracker.tracker_info.add_info_about_infohash('a' * 20, 20, 30) + + yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_with_trackers) + + @trial_timeout(20) + @inlineCallbacks + def test_check_torrent_health_chant(self): + """ + Test the endpoint to fetch the health of a chant-managed, infohash-only torrent + """ + infohash = 'a' * 20 + tracker_url = 'udp://localhost:%s/announce' % self.udp_port + + meta_info = {"info": {"name": "my_torrent", "piece length": 42, + "root hash": infohash, "files": [], + "url-list": tracker_url}} + tdef = TorrentDef.load_from_dict(meta_info) + + with db_session: + self.session.lm.mds.TorrentMetadata(infohash=tdef.infohash, + title='ubuntu-torrent.iso', + size=42, + tracker_info=tracker_url, + health=torrent_state) + url = 'metadata/torrents/%s/health?timeout=10&refresh=1' % hexlify(infohash) + self.should_check_equality = False + + # Initialize the torrent checker + self.session.lm.torrent_checker = TorrentChecker(self.session) + self.session.lm.torrent_checker.initialize() + + def verify_response_no_trackers(response): + json_response = json.loads(response) + expected_dict = { + u"health": { + u"udp://localhost:%d" % self.udp_tracker.port: { + u"leechers": 11, + u"seeders": 12, + u"infohash": six.text_type(hexlify(infohash)) + } + } + } + self.assertDictEqual(json_response, expected_dict) + + # Left for compatibility with other tests in this object + self.udp_tracker.start() + self.http_tracker.start() + yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) diff --git a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py new file mode 100644 index 00000000000..14199ad2cf4 --- /dev/null +++ b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py @@ -0,0 +1,243 @@ +import json +from binascii import hexlify + +from pony.orm import db_session +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW, TODELETE +from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.Core.base_test import MockObject + + +class BaseTestMyChannelEndpoint(AbstractApiTest): + + @inlineCallbacks + def setUp(self): + yield super(BaseTestMyChannelEndpoint, self).setUp() + self.session.lm.gigachannel_manager = MockObject() + self.session.lm.gigachannel_manager.shutdown = lambda: None + self.session.lm.gigachannel_manager.updated_my_channel = lambda _: None + + def create_my_channel(self): + with db_session: + _ = self.session.lm.mds.ChannelMetadata.create_channel('test', 'test') + for ind in xrange(5): + _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % ind, status=NEW, infohash=('%d' % ind) * 20) + for ind in xrange(5, 9): + _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % ind, infohash=('%d' % ind) * 20) + + def setUpPreSession(self): + super(BaseTestMyChannelEndpoint, self).setUpPreSession() + self.config.set_chant_enabled(True) + + +class TestMyChannelEndpoint(BaseTestMyChannelEndpoint): + + def test_get_channel_no_channel(self): + """ + Test whether receiving information from your uncreated channel results in an error + """ + self.should_check_equality = False + return self.do_request('mychannel', expected_code=404) + + def test_get_channel(self): + """ + Test whether receiving information from your channel with the REST API works + """ + self.create_my_channel() + self.should_check_equality = False + return self.do_request('mychannel', expected_code=200) + + def test_edit_channel_missing_params(self): + """ + Test whether updating your uncreated channel with missing parameters results in an error + """ + self.should_check_equality = False + return self.do_request('mychannel', request_type='POST', expected_code=400) + + def test_edit_channel_no_channel(self): + """ + Test whether updating your uncreated channel results in an error + """ + self.should_check_equality = False + post_params = {'name': 'bla', 'description': 'bla'} + return self.do_request('mychannel', request_type='POST', post_data=post_params, expected_code=404) + + def test_edit_channel(self): + """ + Test editing your channel with the REST API works + """ + def on_response(_): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + self.assertEqual(my_channel.title, 'bla') + + self.create_my_channel() + self.should_check_equality = False + post_params = {'name': 'bla', 'description': 'bla'} + return self.do_request('mychannel', request_type='POST', post_data=post_params, expected_code=200)\ + .addCallback(on_response) + + def test_create_channel_missing_name(self): + """ + Test whether creating a channel with a missing name parameter results in an error + """ + self.should_check_equality = False + return self.do_request('mychannel', request_type='PUT', expected_code=400) + + def test_create_channel_exists(self): + """ + Test whether creating a channel again results in an error + """ + self.create_my_channel() + self.should_check_equality = False + post_params = {'name': 'bla', 'description': 'bla'} + return self.do_request('mychannel', request_type='PUT', post_data=post_params, expected_code=409) + + def test_create_channel(self): + """ + Test editing your channel with the REST API works + """ + def on_response(_): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + self.assertTrue(my_channel) + self.assertEqual(my_channel.title, 'bla') + + self.should_check_equality = False + post_params = {'name': 'bla', 'description': 'bla'} + return self.do_request('mychannel', request_type='PUT', post_data=post_params, expected_code=200)\ + .addCallback(on_response) + + +class TestMyChannelCommitEndpoint(BaseTestMyChannelEndpoint): + + def test_commit_no_channel(self): + """ + Test whether we get an error if we try to commit a channel without it being created + """ + self.should_check_equality = False + return self.do_request('mychannel/commit', expected_code=404, request_type='POST') + + def test_commit(self): + """ + Test whether we can successfully commit changes to your channel with the REST API + """ + self.should_check_equality = False + self.create_my_channel() + return self.do_request('mychannel/commit', expected_code=200, request_type='POST') + + +class TestMyChannelTorrentsEndpoint(BaseTestMyChannelEndpoint): + + def test_get_my_torrents_no_channel(self): + """ + Test whether we get an error if we try to get torrents from your channel without it being created + """ + self.should_check_equality = False + return self.do_request('mychannel/torrents', expected_code=404) + + def test_get_my_torrents(self): + """ + Test whether we can query torrents from your channel + """ + def on_response(response): + json_response = json.loads(response) + self.assertEqual(len(json_response['torrents']), 9) + self.assertIn('status', json_response['torrents'][0]) + + self.create_my_channel() + self.should_check_equality = False + return self.do_request('mychannel/torrents', expected_code=200).addCallback(on_response) + + def test_delete_all_torrents_no_channel(self): + """ + Test whether we get an error if we remove all torrents from your uncreated channel + """ + self.should_check_equality = False + return self.do_request('mychannel/torrents', request_type='DELETE', expected_code=404) + + def test_delete_all_torrents(self): + """ + Test whether we can remove all torrents from your channel + """ + def on_response(_): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + torrents = my_channel.contents_list + for torrent in torrents: + self.assertEqual(torrent.status, TODELETE) + + self.should_check_equality = False + self.create_my_channel() + return self.do_request('mychannel/torrents', request_type='DELETE', expected_code=200).addCallback(on_response) + + def test_update_my_torrents_invalid_params(self): + """ + Test whether we get an error if we pass invalid parameters when updating multiple torrents in your channel + """ + self.should_check_equality = False + return self.do_request('mychannel/torrents', request_type='POST', expected_code=400) + + def test_update_my_torrents_no_channel(self): + """ + Test whether we get an error if we update multiple torrents in your uncreated channel + """ + self.should_check_equality = False + post_params = {'status': TODELETE, 'infohashes': '0' * 20} + return self.do_request('mychannel/torrents', request_type='POST', post_data=post_params, expected_code=404) + + def test_update_my_torrents(self): + """ + Test whether we get an error if we update multiple torrents in your uncreated channel + """ + def on_response(_): + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + torrent = my_channel.get_torrent('0' * 20) + self.assertEqual(torrent.status, TODELETE) + + self.should_check_equality = False + self.create_my_channel() + post_params = {'status': TODELETE, 'infohashes': hexlify('0' * 20)} + return self.do_request('mychannel/torrents', request_type='POST', post_data=post_params, expected_code=200)\ + .addCallback(on_response) + + +class TestMyChannelSpecificTorrentEndpoint(BaseTestMyChannelEndpoint): + + def test_update_my_torrent_no_status(self): + """ + Test whether an error is returned if we do not pass the status parameter + """ + self.should_check_equality = False + return self.do_request('mychannel/torrents/abcd', request_type='PATCH', expected_code=400) + + def test_update_my_torrent_no_channel(self): + """ + Test whether an error is returned if your channel is not created when updating your torrents + """ + self.should_check_equality = False + post_params = {'status': TODELETE} + return self.do_request('mychannel/torrents/abcd', + post_data=post_params, request_type='PATCH', expected_code=404) + + def test_update_my_torrent_no_torrent(self): + """ + Test whether an error is returned when updating an unknown torrent in your channel + """ + self.should_check_equality = False + self.create_my_channel() + post_params = {'status': TODELETE} + return self.do_request('mychannel/torrents/abcd', + post_data=post_params, request_type='PATCH', expected_code=404) + + def test_update_my_torrent(self): + """ + Test whether you are able to update a torrent in your channel with the REST API + """ + self.should_check_equality = False + self.create_my_channel() + post_params = {'status': TODELETE} + return self.do_request('mychannel/torrents/%s' % hexlify('0' * 20), + post_data=post_params, request_type='PATCH', expected_code=200) diff --git a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py index 74a1d597b4c..0c0f3a698eb 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py @@ -4,105 +4,37 @@ import random from pony.orm import db_session -from six import unichr from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, NTFY_TORRENTS, SIGNAL_CHANNEL, - SIGNAL_ON_SEARCH_RESULTS, SIGNAL_TORRENT) from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest from Tribler.Test.tools import trial_timeout from Tribler.pyipv8.ipv8.database import database_blob -class FakeSearchManager(object): - """ - This class is used to test whether Tribler starts searching for channels/torrents when a search is performed. - """ - - def __init__(self, notifier): - self.notifier = notifier - - def search_for_torrents(self, keywords): - results_dict = {"keywords": keywords, "result_list": []} - self.notifier.notify(SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - - def search_for_channels(self, keywords): - results_dict = {"keywords": keywords, "result_list": []} - self.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - - def shutdown(self): - pass - - class TestSearchEndpoint(AbstractApiTest): - def __init__(self, *args, **kwargs): - super(TestSearchEndpoint, self).__init__(*args, **kwargs) - self.expected_events_messages = [] - - @inlineCallbacks - def setUp(self): - yield super(TestSearchEndpoint, self).setUp() - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - self.channel_db_handler._get_my_dispersy_cid = lambda: "myfakedispersyid" - self.torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - def setUpPreSession(self): super(TestSearchEndpoint, self).setUpPreSession() self.config.set_chant_enabled(True) - def insert_channels_in_db(self, num): - for i in xrange(0, num): - self.channel_db_handler.on_channel_from_dispersy('rand%d' % i, 42 + i, - 'Test channel %d' % i, 'Test description %d' % i) - - def insert_torrents_in_db(self, num): - for i in xrange(0, num): - ih = "".join(unichr(97 + random.randint(0, 15)) for _ in range(0, 20)) - self.torrent_db_handler.addExternalTorrentNoDef(ih.encode('utf-8'), 'hay %d' % i, [('Test.txt', 1337)], [], - 1337) - @trial_timeout(10) - @inlineCallbacks - def test_search_legacy(self): + def test_search_no_query(self): """ - Test a search query that should return a few new type channels + Testing whether the API returns an error 400 if no query is passed when doing a search """ - - self.insert_channels_in_db(1) - self.insert_torrents_in_db(100) - self.torrent_db_handler.addExternalTorrentNoDef(str(unichr(98)) * 20, 'Needle', [('Test.txt', 1337)], [], 1337) self.should_check_equality = False - - result = yield self.do_request('search?txt=needle', expected_code=200) - parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 1) - - result = yield self.do_request('search?txt=hay&first=10&last=20', expected_code=200) - parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 10) - - """ - torrent_list = [ - [channel_id, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []], - [channel_id, 1, 1, ('b' * 40).decode('hex'), 1460000000, "badterm", [['file1.txt', 42]], []] - ] - self.insert_torrents_into_channel(torrent_list) - """ + return self.do_request('search', expected_code=400) @trial_timeout(10) @inlineCallbacks - def test_search_chant(self): + def test_search(self): """ Test a search query that should return a few new type channels """ - num_hay = 100 with db_session: - my_channel_id = self.session.trustchain_keypair.pub().key_to_bin() - channel = self.session.lm.mds.ChannelMetadata(public_key=database_blob(my_channel_id), title='test', - tags='test', subscribed=True) + _ = self.session.lm.mds.ChannelMetadata(title='test', tags='test', subscribed=True) for x in xrange(0, num_hay): self.session.lm.mds.TorrentMetadata(title='hay ' + str(x), infohash=database_blob( bytearray(random.getrandbits(8) for _ in xrange(20)))) @@ -112,51 +44,42 @@ def test_search_chant(self): self.should_check_equality = False - result = yield self.do_request('search?txt=needle', expected_code=200) + result = yield self.do_request('search?q=needle', expected_code=200) parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 1) + self.assertEqual(len(parsed["results"]), 1) - result = yield self.do_request('search?txt=hay', expected_code=200) + result = yield self.do_request('search?q=hay', expected_code=200) parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), num_hay) + self.assertEqual(len(parsed["results"]), 50) - result = yield self.do_request('search?first=10&last=20', expected_code=200) + result = yield self.do_request('search?q=test&type=channel', expected_code=200) parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 10) + self.assertEqual(len(parsed["results"]), 1) - result = yield self.do_request('search?type=channel', expected_code=200) + result = yield self.do_request('search?q=needle&type=torrent', expected_code=200) parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 1) + self.assertEqual(parsed["results"][0][u'name'], 'needle') - result = yield self.do_request('search?sort_by=-name&type=torrent', expected_code=200) + result = yield self.do_request('search?q=needle&sort_by=name', expected_code=200) parsed = json.loads(result) - self.assertEqual(parsed["torrents"][0][u'name'], 'needle') - - result = yield self.do_request('search?type=channel&subscribed=0', expected_code=200) - parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), 0) - - result = yield self.do_request('search?channel=%s' % str(channel.public_key).encode('hex'), expected_code=200) - parsed = json.loads(result) - self.assertEqual(len(parsed["torrents"]), num_hay + 1) + self.assertEqual(len(parsed["results"]), 1) @trial_timeout(10) def test_completions_no_query(self): """ Testing whether the API returns an error 400 if no query is passed when getting search completion terms """ - expected_json = {"error": "query parameter missing"} - return self.do_request('search/completions', expected_code=400, expected_json=expected_json) + self.should_check_equality = False + return self.do_request('search/completions', expected_code=400) @trial_timeout(10) def test_completions(self): """ Testing whether the API returns the right terms when getting search completion terms """ - torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - torrent_db_handler.getAutoCompleteTerms = lambda keyword, max_terms: ["%s %d" % (keyword, ind) - for ind in xrange(max_terms)] - - expected_json = {"completions": ["tribler %d" % ind for ind in xrange(5)]} + def on_response(response): + json_response = json.loads(response) + self.assertEqual(json_response['completions'], []) - return self.do_request('search/completions?q=tribler', expected_code=200, expected_json=expected_json) + self.should_check_equality = False + return self.do_request('search/completions?q=tribler', expected_code=200).addCallback(on_response) diff --git a/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py deleted file mode 100644 index 9a7ecb623a0..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py +++ /dev/null @@ -1,122 +0,0 @@ -import os -import shutil -from binascii import hexlify -from urllib import pathname2url, quote_plus - -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.TorrentDef import TorrentDef -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.common import UBUNTU_1504_INFOHASH, TORRENT_UBUNTU_FILE -from Tribler.Test.test_as_server import TESTS_DATA_DIR - - -class TestTorrentInfoEndpoint(AbstractApiTest): - - def setUpPreSession(self): - super(TestTorrentInfoEndpoint, self).setUpPreSession() - self.config.set_torrent_store_enabled(True) - - @inlineCallbacks - def test_get_torrentinfo(self): - """ - Testing whether the API returns a correct dictionary with torrent info. - """ - # We intentionally put the file path in a folder with a: - # - "+" which is a reserved URI character - # - "\u0191" which is a unicode character - files_path = os.path.join(self.session_base_dir, u'http_torrent_+\u0191files') - os.mkdir(files_path) - shutil.copyfile(TORRENT_UBUNTU_FILE, os.path.join(files_path, 'ubuntu.torrent')) - - file_server_port = get_random_port() - self.setUpFileServer(file_server_port, files_path) - - def verify_valid_dict(data): - metainfo_dict = json.loads(data, encoding='latin_1') - self.assertTrue('metainfo' in metainfo_dict) - self.assertTrue('info' in metainfo_dict['metainfo']) - - self.should_check_equality = False - yield self.do_request('torrentinfo', expected_code=400) - yield self.do_request('torrentinfo?uri=def', expected_code=400) - - path = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "bak_single.torrent")).encode('utf-8') - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) - - # Corrupt file - path = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "test_rss.xml")).encode('utf-8') - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=500) - - path = "http://localhost:%d/ubuntu.torrent" % file_server_port - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) - - def get_metainfo(infohash, callback, **_): - with open(os.path.join(TESTS_DATA_DIR, "bak_single.torrent"), mode='rb') as torrent_file: - torrent_data = torrent_file.read() - tdef = TorrentDef.load_from_memory(torrent_data) - callback(tdef.get_metainfo()) - - def get_metainfo_timeout(*args, **kwargs): - timeout_cb = kwargs.get('timeout_callback') - timeout_cb('a' * 20) - - path = 'magnet:?xt=urn:btih:%s&dn=%s' % (hexlify(UBUNTU_1504_INFOHASH), quote_plus('test torrent')) - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.get_metainfo = get_metainfo - self.session.lm.ltmgr.shutdown = lambda: None - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) # Cached - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) # Cached - - # mdblob file - path_blob = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "channel.mdblob")).encode('utf-8') - yield self.do_request('torrentinfo?uri=%s' % path_blob, expected_code=200).addCallback(verify_valid_dict) - - # invalid mdblob file - path_blob = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "bad.mdblob")).encode('utf-8') - yield self.do_request('torrentinfo?uri=%s' % path_blob, expected_code=500) - - # non-torrent mdblob file - path_blob = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "delete.mdblob")).encode('utf-8') - yield self.do_request('torrentinfo?uri=%s' % path_blob, expected_code=500) - - self.session.get_collected_torrent = lambda _: 'a8fdsafsdjlfdsafs{}{{{[][]][' # invalid torrent file - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=500) - - path = 'magnet:?xt=urn:ed2k:354B15E68FB8F36D7CD88FF94116CDC1' # No infohash - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=400) - - path = 'magnet:?xt=urn:btih:%s&dn=%s' % ('a' * 40, quote_plus('test torrent')) - self.session.lm.ltmgr.get_metainfo = get_metainfo_timeout - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=408) - - def mocked_save_torrent(*_): - raise TypeError() - - self.session.lm.ltmgr.get_metainfo = get_metainfo - self.session.save_collected_torrent = mocked_save_torrent - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) - - path = 'http://fdsafksdlafdslkdksdlfjs9fsafasdf7lkdzz32.n38/324.torrent' - yield self.do_request('torrentinfo?uri=%s' % path, expected_code=500) - - @trial_timeout(10) - def test_on_got_invalid_metainfo(self): - """ - Test whether the right operations happen when we receive an invalid metainfo object - """ - def get_metainfo(infohash, callback, **_): - callback("abcd") - - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.get_metainfo = get_metainfo - self.session.lm.ltmgr.shutdown = lambda: None - path = 'magnet:?xt=urn:btih:%s&dn=%s' % (hexlify(UBUNTU_1504_INFOHASH), quote_plus('test torrent')) - - self.should_check_equality = False - return self.do_request('torrentinfo?uri=%s' % path, expected_code=500) diff --git a/Tribler/Test/Core/Modules/RestApi/test_torrents_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_torrents_endpoint.py deleted file mode 100644 index 6a2958335c0..00000000000 --- a/Tribler/Test/Core/Modules/RestApi/test_torrents_endpoint.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import absolute_import - -from binascii import hexlify, unhexlify -import time - -import six -from pony.orm import db_session -from twisted.internet.defer import inlineCallbacks - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.TorrentChecker.torrent_checker import TorrentChecker -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_TORRENTS -from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.tools import trial_timeout -from Tribler.Test.util.Tracker.HTTPTracker import HTTPTracker -from Tribler.Test.util.Tracker.UDPTracker import UDPTracker - - -class TestTorrentsEndpoint(AbstractApiTest): - - def setUpPreSession(self): - super(TestTorrentsEndpoint, self).setUpPreSession() - self.config.set_chant_enabled(True) - - @trial_timeout(10) - def test_get_random_torrents(self): - """ - Testing whether random torrents are returned if random torrents are fetched - """ - def verify_torrents(results): - json_results = json.loads(results) - self.assertEqual(len(json_results['torrents']), 2) - - channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - channel_db_handler._get_my_dispersy_cid = lambda: "myfakedispersyid" - channel_id = channel_db_handler.on_channel_from_dispersy('rand', 42, 'Fancy channel', 'Fancy description') - - torrent_list = [ - [channel_id, 1, 1, unhexlify('a' * 40), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []], - [channel_id, 2, 2, unhexlify('b' * 40), 1470000000, "ubuntu2-torrent.iso", [['file2.txt', 42]], []], - [channel_id, 3, 3, unhexlify('c' * 40), 1480000000, "badterm", [['file1.txt', 42]], []], - [channel_id, 4, 4, unhexlify('d' * 40), 1490000000, "badterm", [['file2.txt', 42]], []], - [channel_id, 5, 5, unhexlify('e' * 40), 1500000000, "badterm", [['file3.txt', 42]], []], - ] - channel_db_handler.on_torrents_from_dispersy(torrent_list) - - self.should_check_equality = False - return self.do_request('torrents/random?limit=5', expected_code=200).addCallback(verify_torrents) - - @trial_timeout(10) - def test_random_torrents_negative(self): - """ - Testing whether error 400 is returned when a negative limit is passed to the request to fetch random torrents - """ - expected_json = {"error": "the limit parameter must be a positive number"} - return self.do_request('torrents/random?limit=-5', expected_code=400, expected_json=expected_json) - - @trial_timeout(10) - def test_info_torrent_404(self): - """ - Test whether we get an error 404 if we are fetching info from a non-existing torrent - """ - self.should_check_equality = False - return self.do_request('torrents/%s' % ('a' * 40), expected_code=404) - - @trial_timeout(10) - def test_info_torrent_chant(self): - """ - Testing whether the API returns the right information for a request of a specific chant-managed torrent - """ - infohash_hex = six.text_type(hexlify(b'a' * 20)) - with db_session: - self.session.lm.mds.TorrentMetadata(infohash=infohash_hex.decode('hex'), - title=u'ubuntu-torrent.iso', size=42) - return self.do_request('torrents/%s' % hexlify(b'a' * 20), expected_json={ - u"id": u'', - u"category": u"", - u"infohash": six.text_type(hexlify(b'a' * 20)), - u"name": u'ubuntu-torrent.iso', - u"size": 42, - u"trackers": [], - u"num_seeders": 0, - u"num_leechers": 0, - u"last_tracker_check": 0, - u'files': [] - }) - - @trial_timeout(10) - def test_info_torrent(self): - """ - Testing whether the API returns the right information for a request of a specific torrent - """ - torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu-torrent.iso', [['file1.txt', 42]], - ('udp://trackerurl.com:1234/announce',), time.time()) - - return self.do_request('torrents/%s' % hexlify(b'a' * 20), expected_json={ - u"id": 1, - u"infohash": six.text_type(hexlify(b'a' * 20)), - u"name": u'ubuntu-torrent.iso', - u"size": 42, - u"category": u"Compressed", - u"num_seeders": 0, - u"num_leechers": 0, - u"last_tracker_check": 0, - u"files": [{u"path": u"file1.txt", u"size": 42}], - u"trackers": [u"DHT", u"udp://trackerurl.com:1234"] - }) - - -class TestTorrentTrackersEndpoint(AbstractApiTest): - - @trial_timeout(10) - def test_get_torrent_trackers_404(self): - """ - Testing whether we get an error 404 if we are fetching the trackers of a non-existent torrent - """ - self.should_check_equality = False - return self.do_request('torrents/%s/trackers' % ('a' * 40), expected_code=404) - - @trial_timeout(10) - def test_get_torrent_trackers(self): - """ - Testing whether fetching the trackers of a non-existent torrent is successful - """ - torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu-torrent.iso', [['file1.txt', 42]], - ('udp://trackerurl.com:1234/announce', - 'http://trackerurl.com:4567/announce'), time.time()) - - def verify_trackers(trackers): - self.assertIn('DHT', trackers) - self.assertIn('udp://trackerurl.com:1234', trackers) - self.assertIn('http://trackerurl.com:4567/announce', trackers) - - self.should_check_equality = False - return self.do_request('torrents/%s/trackers' % hexlify(b'a' * 20), - expected_code=200).addCallback(verify_trackers) - - -class TestTorrentHealthEndpoint(AbstractApiTest): - - def setUpPreSession(self): - super(TestTorrentHealthEndpoint, self).setUpPreSession() - self.config.set_chant_enabled(True) - - @inlineCallbacks - def setUp(self): - yield super(TestTorrentHealthEndpoint, self).setUp() - - min_base_port, max_base_port = self.get_bucket_range_port() - - self.udp_port = get_random_port(min_port=min_base_port, max_port=max_base_port) - self.udp_tracker = UDPTracker(self.udp_port) - - self.http_port = get_random_port(min_port=min_base_port, max_port=max_base_port) - self.http_tracker = HTTPTracker(self.http_port) - - @inlineCallbacks - def tearDown(self): - self.session.lm.ltmgr = None - if self.udp_tracker: - yield self.udp_tracker.stop() - if self.http_tracker: - yield self.http_tracker.stop() - yield super(TestTorrentHealthEndpoint, self).tearDown() - - @trial_timeout(20) - @inlineCallbacks - def test_check_torrent_health(self): - """ - Test the endpoint to fetch the health of a torrent - """ - torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu-torrent.iso', [['file1.txt', 42]], - ('udp://localhost:%s/announce' % self.udp_port, - 'http://localhost:%s/announce' % self.http_port), time.time()) - - url = 'torrents/%s/health?timeout=10&refresh=1' % hexlify(b'a' * 20) - - self.should_check_equality = False - yield self.do_request(url, expected_code=400, request_type='GET') # No torrent checker - - def call_cb(infohash, callback, **_): - callback({"seeders": 1, "leechers": 2}) - - # Initialize the torrent checker - self.session.lm.torrent_checker = TorrentChecker(self.session) - self.session.lm.torrent_checker.initialize() - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.get_metainfo = call_cb - - yield self.do_request('torrents/%s/health' % ('f' * 40), expected_code=404, request_type='GET') - - def verify_response_no_trackers(response): - json_response = json.loads(response) - self.assertTrue('DHT' in json_response['health']) - - def verify_response_with_trackers(response): - hex_as = hexlify(b'a' * 20) - json_response = json.loads(response) - expected_dict = {u"health": - {u"DHT": - {u"leechers": 2, u"seeders": 1, u"infohash": hex_as}, - u"udp://localhost:%s" % self.udp_port: - {u"leechers": 20, u"seeders": 10, u"infohash": hex_as}, - u"http://localhost:%s/announce" % self.http_port: - {u"leechers": 30, u"seeders": 20, u"infohash": hex_as}}} - self.assertDictEqual(json_response, expected_dict) - - yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) - - self.udp_tracker.start() - self.udp_tracker.tracker_info.add_info_about_infohash('a' * 20, 10, 20) - - self.http_tracker.start() - self.http_tracker.tracker_info.add_info_about_infohash('a' * 20, 20, 30) - - yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_with_trackers) - - @trial_timeout(20) - @inlineCallbacks - def test_check_torrent_health_chant(self): - """ - Test the endpoint to fetch the health of a chant-managed, infohash-only torrent - """ - infohash = 'a' * 20 - tracker_url = 'udp://localhost:%s/announce' % self.udp_port - - meta_info = {"info": {"name": "my_torrent", "piece length": 42, - "root hash": infohash, "files": [], - "url-list": tracker_url}} - tdef = TorrentDef.load_from_dict(meta_info) - - with db_session: - self.session.lm.mds.TorrentMetadata(infohash=tdef.infohash, - title='ubuntu-torrent.iso', - size=42, - tracker_info=tracker_url) - url = 'torrents/%s/health?timeout=10&refresh=1' % tdef.infohash.encode('hex') - self.should_check_equality = False - - def fake_get_metainfo(_, callback, timeout=10, timeout_callback=None, notify=True): - meta_info_extended = meta_info.copy() - meta_info_extended['seeders'] = 12 - meta_info_extended['leechers'] = 11 - callback(meta_info_extended) - - # Initialize the torrent checker - self.session.lm.torrent_checker = TorrentChecker(self.session) - self.session.lm.torrent_checker.initialize() - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.get_metainfo = fake_get_metainfo - - def verify_response_no_trackers(response): - json_response = json.loads(response) - expected_dict = {u"health": - {u"DHT": - {u"leechers": 11, u"seeders": 12, - u"infohash": six.text_type(tdef.infohash.encode('hex'))}}} - self.assertDictEqual(json_response, expected_dict) - - # Left for compatibility with other tests in this object - self.udp_tracker.start() - self.http_tracker.start() - # TODO: add test for DHT timeout - yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index 4b342cd34c6..0012487a23e 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -40,7 +40,7 @@ def send_random_to(self, peer): # Choose some random entries and try to pack them into maximum_payload_size bytes md_list = [] with db_session: - channel_l = self.metadata_store.ChannelMetadata.get_random_subscribed_channels(1)[:] + channel_l = self.metadata_store.ChannelMetadata.get_random_channels(1)[:] if not channel_l: return channel = channel_l[0] From 3beae42609b4d0fd58ee4ba43b1c9b35136b026a Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Tue, 8 Jan 2019 15:54:32 +0100 Subject: [PATCH 08/38] Removed Dispersy and related code Removed channel management objects Removed TFTP Made TrackerManager use Pony Made TorrentChecker use Pony Removed old Tribler database and tests --- .coveragerc | 1 - .gitmodules | 3 - .pylintrc | 4 +- .../APIImplementation/IPv8EndpointAdapter.py | 65 - Tribler/Core/APIImplementation/IPv8Module.py | 16 - .../Core/APIImplementation/LaunchManyCore.py | 332 +-- Tribler/Core/CacheDB/SqliteCacheDBHandler.py | 2616 ----------------- Tribler/Core/CacheDB/__init__.py | 5 - Tribler/Core/CacheDB/db_versions.py | 35 - Tribler/Core/CacheDB/schema_sdb_v29.sql | 285 -- Tribler/Core/CacheDB/sqlitecachedb.py | 527 ---- Tribler/Core/Config/config.spec | 31 +- Tribler/Core/Config/tribler_config.py | 106 +- .../Core/CreditMining/CreditMiningManager.py | 17 +- .../Core/CreditMining/CreditMiningSource.py | 58 +- .../Core/Libtorrent/LibtorrentDownloadImpl.py | 41 +- .../MetadataStore/OrmBindings/metadata.py | 26 +- .../OrmBindings/torrent_state.py | 5 +- .../OrmBindings/tracker_state.py | 5 +- Tribler/Core/Modules/channel/__init__.py | 3 - Tribler/Core/Modules/channel/cache.py | 46 - Tribler/Core/Modules/channel/channel.py | 130 - .../Core/Modules/channel/channel_manager.py | 127 - Tribler/Core/Modules/channel/channel_rss.py | 268 -- .../Modules/restapi/downloads_endpoint.py | 33 +- .../Core/Modules/restapi/events_endpoint.py | 52 +- .../Core/Modules/restapi/metadata_endpoint.py | 61 +- .../Modules/restapi/mychannel_endpoint.py | 2 + Tribler/Core/Modules/restapi/root_endpoint.py | 4 + .../Core/Modules/restapi/search_endpoint.py | 3 - .../Modules/restapi/statistics_endpoint.py | 90 - .../Modules/restapi/torrentinfo_endpoint.py | 157 + Tribler/Core/Modules/restapi/util.py | 130 - Tribler/Core/Modules/search_manager.py | 216 -- Tribler/Core/Modules/tracker_manager.py | 102 +- Tribler/Core/{CacheDB => }/Notifier.py | 0 Tribler/Core/RemoteTorrentHandler.py | 628 ---- Tribler/Core/Session.py | 256 +- Tribler/Core/TFTP/__init__.py | 3 - Tribler/Core/TFTP/exception.py | 18 - Tribler/Core/TFTP/handler.py | 568 ---- Tribler/Core/TFTP/packet.py | 231 -- Tribler/Core/TFTP/session.py | 52 - .../Core/TorrentChecker/torrent_checker.py | 156 +- Tribler/Core/Upgrade/config_converter.py | 28 - Tribler/Core/Upgrade/db_upgrader.py | 588 ---- Tribler/Core/Upgrade/pickle_converter.py | 115 - Tribler/Core/Upgrade/torrent_upgrade64.py | 224 -- Tribler/Core/Upgrade/torrent_upgrade65.py | 77 - Tribler/Core/Upgrade/upgrade.py | 103 +- Tribler/Core/__init__.py | 55 - Tribler/Core/leveldbstore.py | 161 - Tribler/Core/permid.py | 44 - Tribler/Core/plyveladapter.py | 42 - Tribler/Core/simpledefs.py | 6 +- Tribler/Core/statistics.py | 102 +- Tribler/Test/API/test_download.py | 9 +- .../Test/Community/AbstractTestCommunity.py | 28 - Tribler/Test/Community/Allchannel/__init__.py | 3 - .../Allchannel/test_allchannel_community.py | 43 - .../Community/Search/FullSession/__init__.py | 3 - .../FullSession/test_search_community.py | 179 -- Tribler/Test/Community/Search/__init__.py | 3 - .../Community/Search/test_search_community.py | 89 - .../Tunnel/FullSession/test_tunnel_base.py | 8 +- Tribler/Test/Community/channel/__init__.py | 3 - .../Community/channel/test_channel_base.py | 21 - .../channel/test_channel_community.py | 56 - .../channel/test_channel_conversion.py | 67 - .../Community/popularity/test_community.py | 725 +---- .../Test/Community/popularity/test_payload.py | 159 +- .../popularity/test_pubsub_community.py | 140 + .../Community/popularity/test_repository.py | 422 +-- .../Test/Core/Config/test_tribler_config.py | 75 +- .../test_credit_mining_manager.py | 6 +- .../test_credit_mining_sources.py | 64 +- .../test_libtorrent_download_impl.py | 4 - .../Core/Libtorrent/test_libtorrent_mgr.py | 12 +- Tribler/Test/Core/Modules/Channel/__init__.py | 3 - .../Test/Core/Modules/Channel/test_channel.py | 30 - .../Modules/Channel/test_channel_manager.py | 45 - .../Core/Modules/Channel/test_channel_rss.py | 138 - .../MetadataStore/test_torrent_metadata.py | 4 +- .../Core/Modules/RestApi/base_api_test.py | 11 +- .../RestApi/test_downloads_endpoint.py | 23 +- .../Modules/RestApi/test_events_endpoint.py | 53 +- .../Modules/RestApi/test_market_endpoint.py | 10 +- .../Modules/RestApi/test_metadata_endpoint.py | 71 +- .../RestApi/test_mychannel_endpoint.py | 3 + .../Modules/RestApi/test_settings_endpoint.py | 5 +- .../RestApi/test_statistics_endpoint.py | 31 +- .../RestApi/test_torrentinfo_endpoint.py | 105 + .../Test/Core/Modules/RestApi/test_util.py | 63 +- .../Test/Core/Modules/test_tracker_manager.py | 34 +- .../Test/Core/Modules/test_watch_folder.py | 5 +- Tribler/Test/Core/TFTP/__init__.py | 3 - Tribler/Test/Core/TFTP/test_tftp_handler.py | 227 -- Tribler/Test/Core/TFTP/test_tftp_packet.py | 104 - .../TorrentChecker/test_torrentchecker.py | 60 +- .../Core/Upgrade/test_config_upgrade_70_71.py | 7 +- Tribler/Test/Core/Upgrade/test_db_upgrader.py | 64 - .../Core/Upgrade/test_pickle_converter.py | 67 - .../Upgrade/test_torrent_upgrade_63_64.py | 122 - .../Upgrade/test_torrent_upgrade_64_65.py | 45 - Tribler/Test/Core/Upgrade/test_upgrader.py | 39 +- Tribler/Test/Core/Upgrade/upgrade_base.py | 36 - Tribler/Test/Core/Video/test_vod.py | 11 +- Tribler/Test/Core/base_test_channel.py | 74 - Tribler/Test/Core/test_launch_many_cores.py | 75 +- Tribler/Test/Core/test_leveldb_store.py | 150 - .../Test/Core/test_leveldb_store_plyvel.py | 13 - Tribler/Test/Core/test_notifier.py | 4 +- Tribler/Test/Core/test_permid.py | 21 +- Tribler/Test/Core/test_session.py | 250 +- Tribler/Test/Core/test_sqlitecachedb.py | 231 -- .../Test/Core/test_sqlitecachedbhandler.py | 76 - .../test_sqlitecachedbhandler_channels.py | 126 - .../Core/test_sqlitecachedbhandler_peers.py | 96 - .../test_sqlitecachedbhandler_preferences.py | 101 - .../test_sqlitecachedbhandler_torrents.py | 307 -- .../test_sqlitecachedbhandler_votecasts.py | 82 - Tribler/Test/mocking/__init__.py | 0 Tribler/Test/mocking/channel.py | 17 - Tribler/Test/mocking/download.py | 17 - Tribler/Test/mocking/session.py | 67 - Tribler/Test/test_as_server.py | 23 +- Tribler/Test/util/Tracker/TrackerInfo.py | 1 + Tribler/community/allchannel/__init__.py | 3 - Tribler/community/allchannel/community.py | 698 ----- Tribler/community/allchannel/conversion.py | 130 - Tribler/community/allchannel/message.py | 27 - Tribler/community/allchannel/payload.py | 116 - Tribler/community/channel/__init__.py | 3 - Tribler/community/channel/community.py | 1286 -------- Tribler/community/channel/conversion.py | 448 --- Tribler/community/channel/message.py | 21 - Tribler/community/channel/payload.py | 325 -- Tribler/community/channel/preview.py | 25 - Tribler/community/popularity/community.py | 226 +- Tribler/community/popularity/constants.py | 20 - Tribler/community/popularity/payload.py | 245 +- Tribler/community/popularity/pubsub.py | 10 +- Tribler/community/popularity/repository.py | 178 +- Tribler/community/search/__init__.py | 3 - Tribler/community/search/community.py | 738 ----- Tribler/community/search/conversion.py | 332 --- Tribler/community/search/payload.py | 170 -- Tribler/dispersy | 1 - TriblerGUI/tribler_window.py | 2 - debian/tribler.install | 1 - logger.conf | 49 - twisted/plugins/market_plugin.py | 16 +- twisted/plugins/tribler_plugin.py | 40 +- twisted/plugins/tunnel_helper_plugin.py | 45 +- 154 files changed, 1052 insertions(+), 18434 deletions(-) delete mode 100644 Tribler/Core/APIImplementation/IPv8EndpointAdapter.py delete mode 100644 Tribler/Core/APIImplementation/IPv8Module.py delete mode 100644 Tribler/Core/CacheDB/SqliteCacheDBHandler.py delete mode 100644 Tribler/Core/CacheDB/__init__.py delete mode 100644 Tribler/Core/CacheDB/db_versions.py delete mode 100644 Tribler/Core/CacheDB/schema_sdb_v29.sql delete mode 100644 Tribler/Core/CacheDB/sqlitecachedb.py delete mode 100644 Tribler/Core/Modules/channel/__init__.py delete mode 100644 Tribler/Core/Modules/channel/cache.py delete mode 100644 Tribler/Core/Modules/channel/channel.py delete mode 100644 Tribler/Core/Modules/channel/channel_manager.py delete mode 100644 Tribler/Core/Modules/channel/channel_rss.py create mode 100644 Tribler/Core/Modules/restapi/torrentinfo_endpoint.py delete mode 100644 Tribler/Core/Modules/search_manager.py rename Tribler/Core/{CacheDB => }/Notifier.py (100%) delete mode 100644 Tribler/Core/RemoteTorrentHandler.py delete mode 100644 Tribler/Core/TFTP/__init__.py delete mode 100644 Tribler/Core/TFTP/exception.py delete mode 100644 Tribler/Core/TFTP/handler.py delete mode 100644 Tribler/Core/TFTP/packet.py delete mode 100644 Tribler/Core/TFTP/session.py delete mode 100644 Tribler/Core/Upgrade/db_upgrader.py delete mode 100644 Tribler/Core/Upgrade/pickle_converter.py delete mode 100644 Tribler/Core/Upgrade/torrent_upgrade64.py delete mode 100644 Tribler/Core/Upgrade/torrent_upgrade65.py delete mode 100644 Tribler/Core/leveldbstore.py delete mode 100644 Tribler/Core/plyveladapter.py delete mode 100644 Tribler/Test/Community/AbstractTestCommunity.py delete mode 100644 Tribler/Test/Community/Allchannel/__init__.py delete mode 100644 Tribler/Test/Community/Allchannel/test_allchannel_community.py delete mode 100644 Tribler/Test/Community/Search/FullSession/__init__.py delete mode 100644 Tribler/Test/Community/Search/FullSession/test_search_community.py delete mode 100644 Tribler/Test/Community/Search/__init__.py delete mode 100644 Tribler/Test/Community/Search/test_search_community.py delete mode 100644 Tribler/Test/Community/channel/__init__.py delete mode 100644 Tribler/Test/Community/channel/test_channel_base.py delete mode 100644 Tribler/Test/Community/channel/test_channel_community.py delete mode 100644 Tribler/Test/Community/channel/test_channel_conversion.py create mode 100644 Tribler/Test/Community/popularity/test_pubsub_community.py delete mode 100644 Tribler/Test/Core/Modules/Channel/__init__.py delete mode 100644 Tribler/Test/Core/Modules/Channel/test_channel.py delete mode 100644 Tribler/Test/Core/Modules/Channel/test_channel_manager.py delete mode 100644 Tribler/Test/Core/Modules/Channel/test_channel_rss.py create mode 100644 Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py delete mode 100644 Tribler/Test/Core/TFTP/__init__.py delete mode 100644 Tribler/Test/Core/TFTP/test_tftp_handler.py delete mode 100644 Tribler/Test/Core/TFTP/test_tftp_packet.py delete mode 100644 Tribler/Test/Core/Upgrade/test_db_upgrader.py delete mode 100644 Tribler/Test/Core/Upgrade/test_pickle_converter.py delete mode 100644 Tribler/Test/Core/Upgrade/test_torrent_upgrade_63_64.py delete mode 100644 Tribler/Test/Core/Upgrade/test_torrent_upgrade_64_65.py delete mode 100644 Tribler/Test/Core/base_test_channel.py delete mode 100644 Tribler/Test/Core/test_leveldb_store.py delete mode 100644 Tribler/Test/Core/test_leveldb_store_plyvel.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedb.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler_channels.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler_peers.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler_preferences.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler_torrents.py delete mode 100644 Tribler/Test/Core/test_sqlitecachedbhandler_votecasts.py delete mode 100644 Tribler/Test/mocking/__init__.py delete mode 100644 Tribler/Test/mocking/channel.py delete mode 100644 Tribler/Test/mocking/download.py delete mode 100644 Tribler/Test/mocking/session.py delete mode 100644 Tribler/community/allchannel/__init__.py delete mode 100644 Tribler/community/allchannel/community.py delete mode 100644 Tribler/community/allchannel/conversion.py delete mode 100644 Tribler/community/allchannel/message.py delete mode 100644 Tribler/community/allchannel/payload.py delete mode 100644 Tribler/community/channel/__init__.py delete mode 100644 Tribler/community/channel/community.py delete mode 100644 Tribler/community/channel/conversion.py delete mode 100644 Tribler/community/channel/message.py delete mode 100644 Tribler/community/channel/payload.py delete mode 100644 Tribler/community/channel/preview.py delete mode 100644 Tribler/community/search/__init__.py delete mode 100644 Tribler/community/search/community.py delete mode 100644 Tribler/community/search/conversion.py delete mode 100644 Tribler/community/search/payload.py delete mode 160000 Tribler/dispersy diff --git a/.coveragerc b/.coveragerc index aed1caaf7c1..5f7f3c422a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,4 +1,3 @@ # .coveragerc to control coverage.py [run] branch = True -omit = Tribler/dispersy/* diff --git a/.gitmodules b/.gitmodules index bac6c46120e..ce13b135588 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule "Tribler/dispersy"] - path = Tribler/dispersy - url = https://github.com/Tribler/dispersy.git [submodule "py-ipv8"] path = Tribler/pyipv8 url = https://github.com/Tribler/py-ipv8.git diff --git a/.pylintrc b/.pylintrc index 8886d7dbb65..519f097542d 100644 --- a/.pylintrc +++ b/.pylintrc @@ -9,7 +9,7 @@ # Add files or directories to the blacklist. They should be base names, not # paths. -ignore=.git,dispersy,libnacl,data +ignore=.git,libnacl,data # Pickle collected data for later comparisons. persistent=yes @@ -150,7 +150,7 @@ generated-members= # sudo apt install aspell-en # List of comma separated words that should not be checked. -spelling-ignore-words=Tribler,dispersy +spelling-ignore-words=Tribler # A path to a file that contains private dictionary; one word per line. spelling-private-dict-file= diff --git a/Tribler/Core/APIImplementation/IPv8EndpointAdapter.py b/Tribler/Core/APIImplementation/IPv8EndpointAdapter.py deleted file mode 100644 index bbc623620ee..00000000000 --- a/Tribler/Core/APIImplementation/IPv8EndpointAdapter.py +++ /dev/null @@ -1,65 +0,0 @@ -import socket -from time import time - -from Tribler.pyipv8.ipv8.messaging.interfaces.endpoint import Endpoint - - -class IPv8EndpointAdapter(Endpoint): - """ - Wrap a Dispersy MIMEndpoint as an IPv8 Endpoint - """ - - def __init__(self, mimep): - super(IPv8EndpointAdapter, self).__init__() - mimep.mim = self - self.endpoint = mimep - self._is_open = False - self._prefixes = [] - - def add_listener(self, listener): - super(IPv8EndpointAdapter, self).add_listener(listener) - if hasattr(listener, "_prefix") and listener.__class_.__name__ != "DiscoveryCommunity": - self._prefixes.append(listener._prefix) - - def close(self, timeout=0.0): - """ - Stop the Endpoint. Because we are wrapping a Dispersy endpoint, this does nothing. - Otherwise, Dispersy would error out. - - The proper way of closing the wrapped endpoint would be: - self.endpoint.close(timeout) - """ - pass - - @property - def _port(self): - return self.endpoint._port - - def assert_open(self): - assert self._is_open - - def is_open(self): - return True - - def open(self, dispersy=None): - self._is_open = self.endpoint.open(dispersy) - - def send(self, socket_address, packet): - try: - self.endpoint._socket.sendto(packet, socket_address) - except socket.error: - with self.endpoint._sendqueue_lock: - did_have_senqueue = bool(self.endpoint._sendqueue) - self.endpoint._sendqueue.append((time(), socket_address, packet)) - if not did_have_senqueue: - self.endpoint._process_sendqueue() - - def get_address(self): - return (self.endpoint._ip, self.endpoint._port) - - def data_came_in(self, packets): - for packet in packets: - self.notify_listeners(packet) - if packets: - _, data = packets[0] - return any([data.startswith(prefix) for prefix in self._prefixes]) diff --git a/Tribler/Core/APIImplementation/IPv8Module.py b/Tribler/Core/APIImplementation/IPv8Module.py deleted file mode 100644 index c9345d2715f..00000000000 --- a/Tribler/Core/APIImplementation/IPv8Module.py +++ /dev/null @@ -1,16 +0,0 @@ -from os import path - -from Tribler.Core.APIImplementation.IPv8EndpointAdapter import IPv8EndpointAdapter -from Tribler.pyipv8.ipv8_service import IPv8 -from Tribler.pyipv8.ipv8.configuration import get_default_configuration - - -class IPv8Module(IPv8): - - def __init__(self, mimendpoint, working_dir="."): - config = get_default_configuration() - config['overlays'] = [] - for key in config['keys']: - key['file'] = path.abspath(path.join(working_dir, key['file'])) - config['keys'] = [key for key in config['keys'] if path.isdir(path.dirname(key['file']))] - super(IPv8Module, self).__init__(config, IPv8EndpointAdapter(mimendpoint)) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index d35fb231c85..d150de8a2d2 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -15,24 +15,19 @@ from threading import Event, enumerate as enumerate_threads from traceback import print_exc -from six import text_type -from pony.orm import db_session - -from six import text_type - from twisted.internet import reactor from twisted.internet.defer import Deferred, DeferredList, inlineCallbacks, succeed from twisted.internet.task import LoopingCall from twisted.internet.threads import deferToThread from twisted.python.threadable import isInIOThread -from Tribler.Core.CacheDB.sqlitecachedb import forceDBThread -from Tribler.Core.DownloadConfig import DownloadStartupConfig, DefaultDownloadStartupConfig +from Tribler.Core.Category.Category import Category +from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.Modules.gigachannel_manager import GigaChannelManager from Tribler.Core.Modules.payout_manager import PayoutManager from Tribler.Core.Modules.resource_monitor import ResourceMonitor -from Tribler.Core.Modules.search_manager import SearchManager +from Tribler.Core.Modules.tracker_manager import TrackerManager from Tribler.Core.Modules.versioncheck_manager import VersionCheckManager from Tribler.Core.Modules.wallet.dummy_wallet import DummyWallet1, DummyWallet2 from Tribler.Core.Modules.wallet.tc_wallet import TrustchainWallet @@ -42,13 +37,10 @@ from Tribler.Core.Utilities.configparser import CallbackConfigParser from Tribler.Core.Utilities.install_dir import get_lib_path from Tribler.Core.Video.VideoServer import VideoServer -from Tribler.Core.exceptions import InvalidSignatureException -from Tribler.Core.simpledefs import (DLSTATUS_DOWNLOADING, DLSTATUS_SEEDING, DLSTATUS_STOPPED_ON_ERROR, NTFY_DISPERSY, - NTFY_ERROR, NTFY_FINISHED, NTFY_STARTED, NTFY_TORRENT, NTFY_TORRENTS, NTFY_TRIBLER, - NTFY_UPDATE, STATE_INITIALIZE_CHANNEL_MGR, STATE_LOADING_COMMUNITIES, - STATE_STARTING_DISPERSY, STATE_START_API_ENDPOINTS, STATE_START_CREDIT_MINING, - STATE_START_LIBTORRENT, STATE_START_REMOTE_TORRENT_HANDLER, - STATE_START_TORRENT_CHECKER, STATE_START_WATCH_FOLDER) +from Tribler.Core.simpledefs import (DLSTATUS_DOWNLOADING, DLSTATUS_SEEDING, DLSTATUS_STOPPED_ON_ERROR, NTFY_ERROR, + NTFY_FINISHED, NTFY_STARTED, NTFY_TORRENT, NTFY_TRIBLER, + STATE_START_API_ENDPOINTS, STATE_START_CREDIT_MINING, + STATE_START_LIBTORRENT, STATE_START_TORRENT_CHECKER, STATE_START_WATCH_FOLDER) from Tribler.pyipv8.ipv8.dht.provider import DHTCommunityProvider from Tribler.pyipv8.ipv8.keyvault.private.m2crypto import M2CryptoSK from Tribler.pyipv8.ipv8.peer import Peer @@ -56,7 +48,6 @@ from Tribler.pyipv8.ipv8.peerdiscovery.community import DiscoveryCommunity, PeriodicSimilarity from Tribler.pyipv8.ipv8.peerdiscovery.discovery import EdgeWalk, RandomWalk from Tribler.pyipv8.ipv8.taskmanager import TaskManager -from Tribler.pyipv8.ipv8.util import blockingCallFromThread from Tribler.pyipv8.ipv8_service import IPv8 @@ -84,7 +75,6 @@ def __init__(self): self.initComplete = False self.registered = False - self.dispersy = None self.ipv8 = None self.ipv8_start_time = 0 self.state_cb_count = 0 @@ -104,10 +94,6 @@ def __init__(self): self.shutdownstarttime = None # modules - self.torrent_store = None - self.metadata_store = None - self.rtorrent_handler = None - self.tftp_handler = None self.api_manager = None self.watch_folder = None self.version_check_manager = None @@ -120,8 +106,6 @@ def __init__(self): self.votecast_db = None self.channelcast_db = None - self.search_manager = None - self.channel_manager = None self.gigachannel_manager = None self.video_server = None @@ -151,56 +135,14 @@ def register(self, session, session_lock): self.session = session self.session_lock = session_lock + self.category = Category() + self.tracker_manager = TrackerManager(self.session) + # On Mac, we bundle the root certificate for the SSL validation since Twisted is not using the root # certificates provided by the system trust store. if sys.platform == 'darwin': os.environ['SSL_CERT_FILE'] = os.path.join(get_lib_path(), 'root_certs_mac.pem') - if self.session.config.get_torrent_store_enabled(): - from Tribler.Core.leveldbstore import LevelDbStore - self.torrent_store = LevelDbStore(self.session.config.get_torrent_store_dir()) - if not self.torrent_store.get_db(): - raise RuntimeError("Torrent store (leveldb) is None which should not normally happen") - - if self.session.config.get_metadata_enabled(): - from Tribler.Core.leveldbstore import LevelDbStore - self.metadata_store = LevelDbStore(self.session.config.get_metadata_store_dir()) - if not self.metadata_store.get_db(): - raise RuntimeError("Metadata store (leveldb) is None which should not normally happen") - - # torrent collecting: RemoteTorrentHandler - if self.session.config.get_torrent_collecting_enabled() and self.session.config.get_dispersy_enabled(): - from Tribler.Core.RemoteTorrentHandler import RemoteTorrentHandler - self.rtorrent_handler = RemoteTorrentHandler(self.session) - - # TODO(emilon): move this to a megacache component or smth - if self.session.config.get_megacache_enabled(): - from Tribler.Core.CacheDB.SqliteCacheDBHandler import (PeerDBHandler, TorrentDBHandler, - MyPreferenceDBHandler, VoteCastDBHandler, - ChannelCastDBHandler) - from Tribler.Core.Category.Category import Category - - self._logger.debug('tlm: Reading Session state from %s', self.session.config.get_state_dir()) - - self.category = Category() - - # create DBHandlers - self.peer_db = PeerDBHandler(self.session) - self.torrent_db = TorrentDBHandler(self.session) - self.mypref_db = MyPreferenceDBHandler(self.session) - self.votecast_db = VoteCastDBHandler(self.session) - self.channelcast_db = ChannelCastDBHandler(self.session) - - # initializes DBHandlers - self.peer_db.initialize() - self.torrent_db.initialize() - self.mypref_db.initialize() - self.votecast_db.initialize() - self.channelcast_db.initialize() - - from Tribler.Core.Modules.tracker_manager import TrackerManager - self.tracker_manager = TrackerManager(self.session) - if self.session.config.get_video_server_enabled(): self.video_server = VideoServer(self.session.config.get_video_server_port(), self.session) self.video_server.start() @@ -209,7 +151,7 @@ def register(self, session, session_lock): if self.session.config.get_ipv8_enabled(): from Tribler.pyipv8.ipv8.configuration import get_default_configuration ipv8_config = get_default_configuration() - ipv8_config['port'] = self.session.config.get_dispersy_port() + ipv8_config['port'] = self.session.config.get_ipv8_port() ipv8_config['address'] = self.session.config.get_ipv8_address() ipv8_config['overlays'] = [] ipv8_config['keys'] = [] # We load the keys ourselves @@ -224,33 +166,6 @@ def register(self, session, session_lock): self.session.config.set_anon_proxy_settings(2, ("127.0.0.1", self.session. config.get_tunnel_community_socks5_listen_ports())) - # Dispersy - self.tftp_handler = None - if self.session.config.get_dispersy_enabled(): - from Tribler.dispersy.dispersy import Dispersy - from Tribler.dispersy.endpoint import MIMEndpoint - from Tribler.dispersy.endpoint import IPv8toDispersyAdapter - - # set communication endpoint - if self.session.config.get_ipv8_enabled(): - dispersy_endpoint = IPv8toDispersyAdapter(self.ipv8.endpoint) - else: - dispersy_endpoint = MIMEndpoint(self.session.config.get_dispersy_port()) - - working_directory = text_type(self.session.config.get_state_dir()) - self.dispersy = Dispersy(dispersy_endpoint, working_directory) - self.dispersy.statistics.enable_debug_statistics(False) - - # register TFTP service - from Tribler.Core.TFTP.handler import TftpHandler - self.tftp_handler = TftpHandler(self.session, dispersy_endpoint, "fffffffd".decode('hex'), - block_size=1024) - self.tftp_handler.initialize() - - # Torrent search - if self.session.config.get_torrent_search_enabled() or self.session.config.get_channel_search_enabled(): - self.search_manager = SearchManager(self.session) - self.search_manager.initialize() if not self.initComplete: self.init() @@ -263,6 +178,11 @@ def on_tribler_started(self, subject, changetype, objectID, *args): reactor.callFromThread(self.startup_deferred.callback, None) def load_ipv8_overlays(self): + if self.session.config.get_testnet(): + peer = Peer(self.session.trustchain_testnet_keypair) + else: + peer = Peer(self.session.trustchain_keypair) + # Discovery Community with open(self.session.config.get_permid_keypair_filename(), 'r') as key_file: content = key_file.read() @@ -277,11 +197,6 @@ def load_ipv8_overlays(self): if not self.session.config.get_dispersy_enabled(): self.ipv8.strategies.append((RandomWalk(discovery_community), 20)) - if self.session.config.get_testnet(): - peer = Peer(self.session.trustchain_testnet_keypair) - else: - peer = Peer(self.session.trustchain_keypair) - # TrustChain Community if self.session.config.get_trustchain_enabled(): from Tribler.pyipv8.ipv8.attestation.trustchain.community import TrustChainCommunity, \ @@ -346,7 +261,7 @@ def load_ipv8_overlays(self): from Tribler.community.popularity.community import PopularityCommunity self.popularity_community = PopularityCommunity(peer, self.ipv8.endpoint, self.ipv8.network, - torrent_db=self.session.lm.torrent_db, session=self.session) + metadata_store=self.session.lm.mds, session=self.session) self.ipv8.overlays.append(self.popularity_community) @@ -372,73 +287,7 @@ def enable_ipv8_statistics(self): for overlay in self.ipv8.overlays: self.ipv8.endpoint.enable_community_statistics(overlay.get_prefix(), True) - def load_dispersy_communities(self): - self._logger.info("tribler: Preparing Dispersy communities...") - now_time = timemod.time() - default_kwargs = {'tribler_session': self.session} - - # Search Community - if self.session.config.get_torrent_search_enabled() and self.dispersy: - from Tribler.community.search.community import SearchCommunity - self.dispersy.define_auto_load(SearchCommunity, self.session.dispersy_member, load=True, - kargs=default_kwargs) - - # AllChannel Community - if self.session.config.get_channel_search_enabled() and self.dispersy: - from Tribler.community.allchannel.community import AllChannelCommunity - self.dispersy.define_auto_load(AllChannelCommunity, self.session.dispersy_member, load=True, - kargs=default_kwargs) - - # Channel Community - if self.session.config.get_channel_community_enabled() and self.dispersy: - from Tribler.community.channel.community import ChannelCommunity - self.dispersy.define_auto_load(ChannelCommunity, - self.session.dispersy_member, load=True, kargs=default_kwargs) - - # PreviewChannel Community - if self.session.config.get_preview_channel_community_enabled() and self.dispersy: - from Tribler.community.channel.preview import PreviewChannelCommunity - self.dispersy.define_auto_load(PreviewChannelCommunity, - self.session.dispersy_member, kargs=default_kwargs) - - self._logger.info("tribler: communities are ready in %.2f seconds", timemod.time() - now_time) - def init(self): - if self.dispersy: - from Tribler.dispersy.community import HardKilledCommunity - - self._logger.info("lmc: Starting Dispersy...") - - self.session.readable_status = STATE_STARTING_DISPERSY - now = timemod.time() - success = self.dispersy.start(self.session.autoload_discovery) - - diff = timemod.time() - now - if success: - self._logger.info("lmc: Dispersy started successfully in %.2f seconds [port: %d]", - diff, self.dispersy.wan_address[1]) - else: - self._logger.info("lmc: Dispersy failed to start in %.2f seconds", diff) - - self.upnp_ports.append((self.dispersy.wan_address[1], 'UDP')) - - from Tribler.dispersy.crypto import M2CryptoSK - private_key = self.dispersy.crypto.key_to_bin( - M2CryptoSK(filename=self.session.config.get_permid_keypair_filename())) - self.session.dispersy_member = blockingCallFromThread(reactor, self.dispersy.get_member, - private_key=private_key) - - blockingCallFromThread(reactor, self.dispersy.define_auto_load, HardKilledCommunity, - self.session.dispersy_member, load=True) - - if self.session.config.get_megacache_enabled(): - self.dispersy.database.attach_commit_callback(self.session.sqlite_db.commit_now) - - # notify dispersy finished loading - self.session.notifier.notify(NTFY_DISPERSY, NTFY_STARTED, None) - - self.session.readable_status = STATE_LOADING_COMMUNITIES - # Wallets if self.session.config.get_bitcoinlib_enabled(): try: @@ -471,18 +320,9 @@ def init(self): self.load_ipv8_overlays() self.enable_ipv8_statistics() - if self.dispersy: - self.load_dispersy_communities() - tunnel_community_ports = self.session.config.get_tunnel_community_socks5_listen_ports() self.session.config.set_anon_proxy_settings(2, ("127.0.0.1", tunnel_community_ports)) - if self.session.config.get_channel_search_enabled() and self.session.config.get_dispersy_enabled(): - self.session.readable_status = STATE_INITIALIZE_CHANNEL_MGR - from Tribler.Core.Modules.channel.channel_manager import ChannelManager - self.channel_manager = ChannelManager(self.session) - self.channel_manager.initialize() - if self.session.config.get_libtorrent_enabled(): self.session.readable_status = STATE_START_LIBTORRENT from Tribler.Core.Libtorrent.LibtorrentMgr import LibtorrentMgr @@ -497,10 +337,6 @@ def init(self): self.torrent_checker = TorrentChecker(self.session) self.torrent_checker.initialize() - if self.rtorrent_handler and self.session.config.get_dispersy_enabled(): - self.session.readable_status = STATE_START_REMOTE_TORRENT_HANDLER - self.rtorrent_handler.initialize() - if self.api_manager: self.session.readable_status = STATE_START_API_ENDPOINTS self.api_manager.root_endpoint.start_endpoints() @@ -569,24 +405,6 @@ def add(self, tdef, dscfg, pstate=None, setupDelay=0, hidden=False, share_mode=share_mode, checkpoint_disabled=checkpoint_disabled) setup_deferred.addCallback(self.on_download_handle_created) - if d and not hidden and self.session.config.get_megacache_enabled(): - @forceDBThread - def write_my_pref(): - torrent_id = self.torrent_db.getTorrentID(infohash) - data = {'destination_path': d.get_dest_dir()} - self.mypref_db.addMyPreference(torrent_id, data) - - if isinstance(tdef, TorrentDefNoMetainfo): - self.torrent_db.addOrGetTorrentID(tdef.get_infohash()) - self.torrent_db.updateTorrent(tdef.get_infohash(), name=tdef.get_name_as_unicode()) - self.torrent_db._db.commit_now() - write_my_pref() - elif self.rtorrent_handler: - self.rtorrent_handler.save_torrent(tdef, write_my_pref) - else: - self.torrent_db.addExternalTorrent(tdef, extra_info={'status': 'good'}) - write_my_pref() - return d def on_download_handle_created(self, download): @@ -610,16 +428,6 @@ def remove(self, d, removecontent=False, removestate=True, hidden=False): return out or succeed(None) - def remove_id(self, infohash): - @forceDBThread - def do_db(): - torrent_id = self.torrent_db.getTorrentID(infohash) - if torrent_id: - self.mypref_db.deletePreference(torrent_id) - - if self.session.config.get_megacache_enabled(): - do_db() - def get_downloads(self): """ Called by any thread """ with self.session_lock: @@ -689,21 +497,6 @@ def update_trackers(self, infohash, trackers): dl.set_def(new_def) dl.checkpoint() - if isinstance(old_def, TorrentDefNoMetainfo): - @forceDBThread - def update_trackers_db(infohash, new_trackers): - torrent_id = self.torrent_db.getTorrentID(infohash) - if torrent_id is not None: - self.torrent_db.addTorrentTrackerMappingInBatch(torrent_id, new_trackers) - self.session.notifier.notify(NTFY_TORRENTS, NTFY_UPDATE, infohash) - - if self.session.config.get_megacache_enabled(): - update_trackers_db(infohash, new_trackers) - - elif not isinstance(old_def, TorrentDefNoMetainfo) and self.rtorrent_handler: - # Update collected torrents - self.rtorrent_handler.save_torrent(new_def) - # # State retrieval # @@ -828,41 +621,19 @@ def load_download_pstate_noexc(self, infohash): def resume_download(self, filename, setupDelay=0): tdef = dscfg = pstate = None - try: - pstate = self.load_download_pstate(filename) - - # SWIFTPROC - metainfo = pstate.get('state', 'metainfo') - if 'infohash' in metainfo: - tdef = TorrentDefNoMetainfo(metainfo['infohash'], metainfo['name'], metainfo.get('url', None)) - else: - tdef = TorrentDef.load_from_dict(metainfo) - - if pstate.has_option('download_defaults', 'saveas') and \ - isinstance(pstate.get('download_defaults', 'saveas'), tuple): - pstate.set('download_defaults', 'saveas', pstate.get('download_defaults', 'saveas')[-1]) + pstate = self.load_download_pstate(filename) - dscfg = DownloadStartupConfig(pstate) - - except: - # pstate is invalid or non-existing - _, file = os.path.split(filename) - - infohash = binascii.unhexlify(file[:-6]) + metainfo = pstate.get('state', 'metainfo') + if 'infohash' in metainfo: + tdef = TorrentDefNoMetainfo(metainfo['infohash'], metainfo['name'], metainfo.get('url', None)) + else: + tdef = TorrentDef.load_from_dict(metainfo) - torrent_data = self.torrent_store.get(infohash) - if torrent_data: - try: - tdef = TorrentDef.load_from_memory(torrent_data) - defaultDLConfig = DefaultDownloadStartupConfig.getInstance() - dscfg = defaultDLConfig.copy() + if pstate.has_option('download_defaults', 'saveas') and \ + isinstance(pstate.get('download_defaults', 'saveas'), tuple): + pstate.set('download_defaults', 'saveas', pstate.get('download_defaults', 'saveas')[-1]) - if self.mypref_db is not None: - dest_dir = self.mypref_db.getMyPrefStatsInfohash(infohash) - if dest_dir and os.path.isdir(dest_dir): - dscfg.set_dest_dir(dest_dir) - except ValueError: - self._logger.warning("tlm: torrent data invalid") + dscfg = DownloadStartupConfig(pstate) if pstate is not None: has_resume_data = pstate.get('state', 'engineresumedata') is not None @@ -926,7 +697,8 @@ def do_remove(): self._logger.exception("Could not remove state") else: self._logger.warning("remove pstate: download is back, restarted? Canceling removal! %s", - repr(infohash)) + repr(infohash)) + reactor.callFromThread(do_remove) @inlineCallbacks @@ -952,26 +724,11 @@ def early_shutdown(self): yield self.torrent_checker.shutdown() self.torrent_checker = None - if self.channel_manager: - self.session.notify_shutdown_state("Shutting down Channel Manager...") - yield self.channel_manager.shutdown() - self.channel_manager = None - if self.gigachannel_manager: self.session.notify_shutdown_state("Shutting down Gigachannel Manager...") yield self.gigachannel_manager.shutdown() self.gigachannel_manager = None - if self.search_manager: - self.session.notify_shutdown_state("Shutting down Search Manager...") - yield self.search_manager.shutdown() - self.search_manager = None - - if self.rtorrent_handler: - self.session.notify_shutdown_state("Shutting down Remote Torrent Handler...") - yield self.rtorrent_handler.shutdown() - self.rtorrent_handler = None - if self.video_server: self.session.notify_shutdown_state("Shutting down Video Server...") yield self.video_server.shutdown_server() @@ -989,11 +746,6 @@ def early_shutdown(self): self.tracker_manager = None - if self.tftp_handler is not None: - self.session.notify_shutdown_state("Shutting down TFTP Handler...") - yield self.tftp_handler.shutdown() - self.tftp_handler = None - if self.tunnel_community and self.trustchain_community: # We unload these overlays manually since the TrustChain has to be unloaded after the tunnel overlay. tunnel_community = self.tunnel_community @@ -1005,31 +757,10 @@ def early_shutdown(self): self.session.notify_shutdown_state("Shutting down TrustChain Community...") yield self.ipv8.unload_overlay(trustchain_community) - if self.dispersy: - self._logger.info("lmc: Shutting down Dispersy...") - self.session.notify_shutdown_state("Shutting down Dispersy...") - now = timemod.time() - try: - success = yield self.dispersy.stop() - except: - print_exc() - success = False - - diff = timemod.time() - now - if success: - self._logger.info("lmc: Dispersy successfully shutdown in %.2f seconds", diff) - else: - self._logger.info("lmc: Dispersy failed to shutdown in %.2f seconds", diff) - if self.ipv8: self.session.notify_shutdown_state("Shutting down IPv8...") yield self.ipv8.stop(stop_reactor=False) - if self.metadata_store is not None: - self.session.notify_shutdown_state("Shutting down Metadata Store...") - yield self.metadata_store.close() - self.metadata_store = None - if self.channelcast_db is not None: self.session.notify_shutdown_state("Shutting down ChannelCast DB...") yield self.channelcast_db.close() @@ -1055,11 +786,6 @@ def early_shutdown(self): yield self.peer_db.close() self.peer_db = None - if self.torrent_store is not None: - self.session.notify_shutdown_state("Shutting down Torrent Store...") - yield self.torrent_store.close() - self.torrent_store = None - if self.watch_folder is not None: self.session.notify_shutdown_state("Shutting down Watch Folder...") yield self.watch_folder.stop() diff --git a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py b/Tribler/Core/CacheDB/SqliteCacheDBHandler.py deleted file mode 100644 index 90cfba05e6b..00000000000 --- a/Tribler/Core/CacheDB/SqliteCacheDBHandler.py +++ /dev/null @@ -1,2616 +0,0 @@ -from __future__ import absolute_import -""" -SqlitecacheDBHanler. - -Author(s): Jie Yang -""" -import logging -import math -import os -import threading -from collections import OrderedDict, defaultdict -from copy import deepcopy -from itertools import chain -from libtorrent import bencode -from pprint import pformat -from struct import unpack_from -from time import time -from traceback import print_exc - -from twisted.internet.task import LoopingCall - -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.CacheDB.sqlitecachedb import bin2str, str2bin -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.search_utils import split_into_keywords, filter_keywords -from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url -from Tribler.Core.Utilities.unicode import dunno2unicode -from Tribler.Core.simpledefs import (INFOHASH_LENGTH, NTFY_UPDATE, NTFY_INSERT, NTFY_DELETE, NTFY_CREATE, - NTFY_MODIFIED, NTFY_TRACKERINFO, NTFY_MYPREFERENCES, NTFY_VOTECAST, NTFY_TORRENTS, - NTFY_CHANNELCAST, NTFY_COMMENTS, NTFY_PLAYLISTS, NTFY_MODIFICATIONS, - NTFY_MODERATIONS, NTFY_MARKINGS, NTFY_STATE, - SIGNAL_CHANNEL_COMMUNITY, SIGNAL_ON_TORRENT_UPDATED) -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - -VOTECAST_FLUSH_DB_INTERVAL = 15 - -DEFAULT_ID_CACHE_SIZE = 1024 * 5 - - -class LimitedOrderedDict(OrderedDict): - - def __init__(self, limit, *args, **kargs): - super(LimitedOrderedDict, self).__init__(*args, **kargs) - self._limit = limit - - def __setitem__(self, *args, **kargs): - super(LimitedOrderedDict, self).__setitem__(*args, **kargs) - if len(self) > self._limit: - self.popitem(last=False) - - -class BasicDBHandler(TaskManager): - - def __init__(self, session, table_name): - super(BasicDBHandler, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - - self.session = session - self._db = self.session.sqlite_db - self.table_name = table_name - self.notifier = session.notifier - - def initialize(self, *args, **kwargs): - """ - Initializes this DBHandler. - """ - pass - - def close(self): - self.shutdown_task_manager() - - def size(self): - return self._db.size(self.table_name) - - def getOne(self, value_name, where=None, conj=u"AND", **kw): - return self._db.getOne(self.table_name, value_name, where=where, conj=conj, **kw) - - def getAll(self, value_name, where=None, group_by=None, having=None, order_by=None, limit=None, offset=None, - conj=u"AND", **kw): - return self._db.getAll(self.table_name, value_name, where=where, group_by=group_by, having=having, - order_by=order_by, limit=limit, offset=offset, conj=conj, **kw) - - -class PeerDBHandler(BasicDBHandler): - - def __init__(self, session): - super(PeerDBHandler, self).__init__(session, u"Peer") - - self.permid_id = LimitedOrderedDict(DEFAULT_ID_CACHE_SIZE) - - def getPeerID(self, permid): - return self.getPeerIDS([permid, ])[0] - - def getPeerIDS(self, permids): - to_select = [] - - for permid in permids: - assert isinstance(permid, str), permid - - if permid not in self.permid_id: - to_select.append(bin2str(permid)) - - if len(to_select) > 0: - parameters = u", ".join(u'?' * len(to_select)) - sql_get_peer_ids = u"SELECT peer_id, permid FROM Peer WHERE permid IN (%s)" % parameters - peerids = self._db.fetchall(sql_get_peer_ids, to_select) - for peer_id, permid in peerids: - self.permid_id[str2bin(permid)] = peer_id - - to_return = [] - for permid in permids: - if permid in self.permid_id: - to_return.append(self.permid_id[permid]) - else: - to_return.append(None) - return to_return - - def addOrGetPeerID(self, permid): - peer_id = self.getPeerID(permid) - if peer_id is None: - self.addPeer(permid, {}) - peer_id = self.getPeerID(permid) - - return peer_id - - def getPeer(self, permid, keys=None): - if keys is not None: - res = self.getOne(keys, permid=bin2str(permid)) - return res - else: - # return a dictionary - # make it compatible for calls to old bsddb interface - value_name = (u'peer_id', u'permid', u'name') - - item = self.getOne(value_name, permid=bin2str(permid)) - if not item: - return None - peer = dict(zip(value_name, item)) - peer['permid'] = str2bin(peer['permid']) - return peer - - def getPeerById(self, peer_id, keys=None): - if keys is not None: - res = self.getOne(keys, peer_id=peer_id) - return res - else: - # return a dictionary - # make it compatible for calls to old bsddb interface - value_name = (u'peer_id', u'permid', u'name') - - item = self.getOne(value_name, peer_id=peer_id) - if not item: - return None - peer = dict(zip(value_name, item)) - peer['permid'] = str2bin(peer['permid']) - return peer - - def addPeer(self, permid, value): - # add or update a peer - # ARNO: AAARGGH a method that silently changes the passed value param!!! - # Jie: deepcopy(value)? - - _permid = None - if 'permid' in value: - _permid = value.pop('permid') - - peer_id = self.getPeerID(permid) - if 'name' in value: - value['name'] = dunno2unicode(value['name']) - if peer_id is not None: - where = u'peer_id == %d' % peer_id - self._db.update('Peer', where, **value) - else: - self._db.insert_or_ignore('Peer', permid=bin2str(permid), **value) - - if _permid is not None: - value['permid'] = permid - - def hasPeer(self, permid, check_db=False): - if not check_db: - return bool(self.getPeerID(permid)) - else: - permid_str = bin2str(permid) - sql_get_peer_id = u"SELECT peer_id FROM Peer WHERE permid == ?" - peer_id = self._db.fetchone(sql_get_peer_id, (permid_str,)) - if peer_id is None: - return False - else: - return True - - def deletePeer(self, permid=None, peer_id=None): - # don't delete friend of superpeers, except that force is True - if peer_id is None: - peer_id = self.getPeerID(permid) - if peer_id is None: - return - - self._db.delete(u"Peer", peer_id=peer_id) - deleted = not self.hasPeer(permid, check_db=True) - if deleted and permid in self.permid_id: - self.permid_id.pop(permid) - - -class TorrentDBHandler(BasicDBHandler): - - def __init__(self, session): - super(TorrentDBHandler, self).__init__(session, u"Torrent") - - self.torrent_dir = None - - self.keys = ['torrent_id', 'name', 'length', 'creation_date', 'num_files', - 'insert_time', 'secret', 'relevance', 'category', 'status', - 'num_seeders', 'num_leechers', 'comment', 'last_tracker_check', 'is_collected'] - self.existed_torrents = set() - - self.value_name = ['C.torrent_id', 'category', 'status', 'name', 'creation_date', 'num_files', - 'num_leechers', 'num_seeders', 'length', 'secret', 'insert_time', - 'relevance', 'infohash', 'last_tracker_check'] - - self.value_name_for_channel = ['C.torrent_id', 'infohash', 'name', 'length', - 'creation_date', 'num_files', 'insert_time', 'secret', - 'relevance', 'category', 'status', - 'num_seeders', 'num_leechers', 'comment'] - - self.category = None - self.mypref_db = self.votecast_db = self.channelcast_db = self._rtorrent_handler = None - - self.infohash_id = LimitedOrderedDict(DEFAULT_ID_CACHE_SIZE) - - # We are saving the latest match info object we got so we can assign a relevance score - # to incoming remote torrents without doing a full text search. - self.latest_matchinfo_torrent = None - - def initialize(self, *args, **kwargs): - super(TorrentDBHandler, self).initialize(*args, **kwargs) - self.category = self.session.lm.category - self.mypref_db = self.session.open_dbhandler(NTFY_MYPREFERENCES) - self.votecast_db = self.session.open_dbhandler(NTFY_VOTECAST) - self.channelcast_db = self.session.open_dbhandler(NTFY_CHANNELCAST) - self._rtorrent_handler = self.session.lm.rtorrent_handler - - def close(self): - super(TorrentDBHandler, self).close() - self.category = None - self.mypref_db = None - self.votecast_db = None - self.channelcast_db = None - self._rtorrent_handler = None - - def getTorrentID(self, infohash): - return self.getTorrentIDS([infohash, ]).get(infohash) - - def getTorrentIDS(self, infohashes): - unique_infohashes = set(infohashes) - - to_return = {} - - to_select = [] - for infohash in unique_infohashes: - assert isinstance(infohash, str), "INFOHASH has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, "INFOHASH has invalid length: %d" % len(infohash) - - if infohash in self.infohash_id: - to_return[infohash] = self.infohash_id[infohash] - else: - to_select.append(bin2str(infohash)) - - parameters = '?,' * len(to_select) - parameters = parameters[:-1] - sql_stmt = u"SELECT torrent_id, infohash FROM Torrent WHERE infohash IN (%s)" % parameters - torrents = self._db.fetchall(sql_stmt, to_select) - for torrent_id, infohash in torrents: - self.infohash_id[str2bin(infohash)] = torrent_id - - for infohash in unique_infohashes: - if infohash not in to_return: - to_return[infohash] = self.infohash_id.get(infohash) - - if __debug__ and len(to_return) != len(unique_infohashes): - self._logger.error("to_return doesn't match infohashes:") - self._logger.error("to_return:") - self._logger.error(pformat(to_return)) - self._logger.error("infohashes:") - self._logger.error(pformat([bin2str(infohash) for infohash in unique_infohashes])) - assert len(to_return) == len(unique_infohashes), (len(to_return), len(unique_infohashes)) - - return to_return - - def getTorrentFiles(self, torrent_id): - return self._db.fetchall("SELECT path, length FROM TorrentFiles WHERE torrent_id = ?", (torrent_id,)) - - def getInfohash(self, torrent_id): - sql_get_infohash = "SELECT infohash FROM Torrent WHERE torrent_id==?" - ret = self._db.fetchone(sql_get_infohash, (torrent_id,)) - if ret: - ret = str2bin(ret) - return ret - - def hasTorrent(self, infohash): - assert isinstance(infohash, str), "INFOHASH has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, "INFOHASH has invalid length: %d" % len(infohash) - if infohash in self.existed_torrents: # to do: not thread safe - return True - infohash_str = bin2str(infohash) - existed = self._db.getOne('CollectedTorrent', 'torrent_id', infohash=infohash_str) - if existed is None: - return False - else: - self.existed_torrents.add(infohash) - return True - - def addExternalTorrent(self, torrentdef, extra_info={}): - assert isinstance(torrentdef, TorrentDef), "TORRENTDEF has invalid type: %s" % type(torrentdef) - assert torrentdef.is_finalized(), "TORRENTDEF is not finalized" - infohash = torrentdef.get_infohash() - if not self.hasTorrent(infohash): - torrent_id = self._addTorrentToDB(torrentdef, extra_info) - files = sorted(torrentdef.get_files_with_length(), key=lambda x: x[0]) - insert_files = [(torrent_id, unicode(path), length) for path, length in files] - sql_insert_files = "INSERT OR IGNORE INTO TorrentFiles (torrent_id, path, length) VALUES (?,?,?)" - self._db.executemany(sql_insert_files, insert_files) - self.notifier.notify(NTFY_TORRENTS, NTFY_INSERT, infohash) - - def addExternalTorrentNoDef(self, infohash, name, files, trackers, timestamp, extra_info={}): - if self.hasTorrent(infohash): - return - metainfo = {'info': {}, 'encoding': 'utf_8'} - metainfo['info']['name'] = name.encode('utf_8') - metainfo['info']['piece length'] = -1 - metainfo['info']['pieces'] = '' - - if len(files) > 1: - files_as_dict = [] - for filename, file_length in files: - filename = filename.encode('utf_8') - files_as_dict.append({'path': [filename], 'length': file_length}) - metainfo['info']['files'] = files_as_dict - - elif len(files) == 1: - metainfo['info']['length'] = files[0][1] - else: - return - - if len(trackers) > 0: - metainfo['announce'] = trackers[0] - metainfo['announce-list'] = [list(trackers)] - else: - metainfo['nodes'] = [] - - metainfo['creation date'] = timestamp - - try: - torrentdef = TorrentDef.load_from_dict(metainfo) - torrentdef.infohash = infohash - - torrent_id = self._addTorrentToDB(torrentdef, extra_info) - if self._rtorrent_handler: - self._rtorrent_handler.notify_possible_torrent_infohash(infohash) - - insert_files = [(torrent_id, unicode(path), length) for path, length in files] - sql_insert_files = "INSERT OR IGNORE INTO TorrentFiles (torrent_id, path, length) VALUES (?,?,?)" - self._db.executemany(sql_insert_files, insert_files) - except: - self._logger.error("Could not create a TorrentDef instance %r %r %r %r %r %r", - infohash, timestamp, name, files, trackers, extra_info) - - def addOrGetTorrentID(self, infohash): - assert isinstance(infohash, str), "INFOHASH has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, "INFOHASH has invalid length: %d" % len(infohash) - - torrent_id = self.getTorrentID(infohash) - if torrent_id is None: - self._db.insert('Torrent', infohash=bin2str(infohash), status=u'unknown') - torrent_id = self.getTorrentID(infohash) - return torrent_id - - def addOrGetTorrentIDSReturn(self, infohashes): - to_be_inserted = set() - torrent_id_results = self.getTorrentIDS(infohashes) - for infohash, torrent_id in torrent_id_results.iteritems(): - if torrent_id is None: - to_be_inserted.add(infohash) - - sql = "INSERT INTO Torrent (infohash, status) VALUES (?, ?)" - self._db.executemany(sql, [(bin2str(infohash), u'unknown') for infohash in to_be_inserted]) - - torrent_id_results = self.getTorrentIDS(infohashes) - torrent_ids = [] - for infohash in infohashes: - torrent_ids.append(torrent_id_results[infohash]) - assert all(torrent_id for torrent_id in torrent_ids), torrent_ids - return torrent_ids, to_be_inserted - - def _get_database_dict(self, torrentdef, extra_info={}): - assert isinstance(torrentdef, TorrentDef), "TORRENTDEF has invalid type: %s" % type(torrentdef) - assert torrentdef.is_finalized(), "TORRENTDEF is not finalized" - - dict = {"infohash": bin2str(torrentdef.get_infohash()), - "name": torrentdef.get_name_as_unicode(), - "length": torrentdef.get_length(), - "creation_date": torrentdef.get_creation_date(), - "num_files": len(torrentdef.get_files()), - "insert_time": long(time()), - "secret": 1 if torrentdef.is_private() else 0, - "relevance": 0.0, - "category": self.category.calculateCategory(torrentdef.metainfo, torrentdef.get_name_as_unicode()), - "status": extra_info.get("status", "unknown"), - "comment": torrentdef.get_comment_as_unicode(), - "is_collected": extra_info.get('is_collected', 0) - } - - if extra_info.get("seeder", -1) != -1: - dict["num_seeders"] = extra_info["seeder"] - if extra_info.get("leecher", -1) != -1: - dict["num_leechers"] = extra_info["leecher"] - - return dict - - def _addTorrentToDB(self, torrentdef, extra_info): - assert isinstance(torrentdef, TorrentDef), "TORRENTDEF has invalid type: %s" % type(torrentdef) - assert torrentdef.is_finalized(), "TORRENTDEF is not finalized" - - infohash = torrentdef.get_infohash() - swarmname = torrentdef.get_name_as_unicode() - database_dict = self._get_database_dict(torrentdef, extra_info) - - # see if there is already a torrent in the database with this infohash - torrent_id = self.getTorrentID(infohash) - if torrent_id is None: # not in database - self._db.insert("Torrent", **database_dict) - torrent_id = self.getTorrentID(infohash) - - else: # infohash in db - del database_dict["infohash"] # no need for infohash, its already stored - where = "torrent_id = %d" % torrent_id - self._db.update('Torrent', where=where, **database_dict) - - if not torrentdef.is_multifile_torrent(): - swarmname, _ = os.path.splitext(swarmname) - self._indexTorrent(torrent_id, swarmname, torrentdef.get_files()) - - self._addTorrentTracker(torrent_id, torrentdef, extra_info) - return torrent_id - - def _indexTorrent(self, torrent_id, swarmname, files): - # Niels: new method for indexing, replaces invertedindex - # Making sure that swarmname does not include extension for single file torrents - swarm_keywords = " ".join(split_into_keywords(swarmname)) - - filedict = {} - fileextensions = set() - for filename in files: - filename, extension = os.path.splitext(filename) - for keyword in split_into_keywords(filename, to_filter_stopwords=True): - filedict[keyword] = filedict.get(keyword, 0) + 1 - - fileextensions.add(extension[1:]) - - filenames = filedict.keys() - if len(filenames) > 1000: - def popSort(a, b): - return filedict[a] - filedict[b] - - filenames.sort(cmp=popSort, reverse=True) - filenames = filenames[:1000] - - values = (torrent_id, swarm_keywords, " ".join(filenames), " ".join(fileextensions)) - try: - # INSERT OR REPLACE not working for fts3 table - self._db.execute_write(u"DELETE FROM FullTextIndex WHERE rowid = ?", (torrent_id,)) - self._db.execute_write( - u"INSERT INTO FullTextIndex (rowid, swarmname, filenames, fileextensions) VALUES(?,?,?,?)", values) - except: - # this will fail if the fts3 module cannot be found - print_exc() - - # ------------------------------------------------------------ - # Adds the trackers of a given torrent into the database. - # ------------------------------------------------------------ - def _addTorrentTracker(self, torrent_id, torrentdef, extra_info={}): - # Set add_all to True if you want to put all multi-trackers into db. - # In the current version (4.2) only the main tracker is used. - - announce = torrentdef.get_tracker() - announce_list = torrentdef.get_tracker_hierarchy() - - # check if to use DHT - new_tracker_set = set() - if torrentdef.is_private(): - new_tracker_set.add(u'no-DHT') - else: - new_tracker_set.add(u'DHT') - - # get rid of junk trackers - # prepare the tracker list to add - if announce: - tracker_url = get_uniformed_tracker_url(announce) - if tracker_url: - new_tracker_set.add(tracker_url) - if announce_list: - for tier in announce_list: - for tracker in tier: - tracker_url = get_uniformed_tracker_url(tracker) - if tracker_url: - new_tracker_set.add(tracker_url) - - # add trackers in batch - self.addTorrentTrackerMappingInBatch(torrent_id, list(new_tracker_set)) - - def updateTorrent(self, infohash, notify=True, **kw): # watch the schema of database - if 'seeder' in kw: - kw['num_seeders'] = kw.pop('seeder') - if 'leecher' in kw: - kw['num_leechers'] = kw.pop('leecher') - - for key in kw.keys(): - if key not in self.keys: - kw.pop(key) - - if len(kw) > 0: - infohash_str = bin2str(infohash) - where = "infohash='%s'" % infohash_str - self._db.update(self.table_name, where, **kw) - - if notify: - self.notifier.notify(NTFY_TORRENTS, NTFY_UPDATE, infohash) - - def update_torrent_with_metainfo(self, infohash, metainfo): - """ Updates name, length and num files from metainfo if record does not exist in the database. """ - torrent_id = self.addOrGetTorrentID(infohash) - name = self.getOne('name', torrent_id=torrent_id) - if not name: - num_files, length = 0, 0 - if 'info' in metainfo: - info = metainfo['info'] - name = u''.join([unichr(ord(c)) for c in info["name"]]) if "name" in info else "" - if 'files' in info: - num_files = len(info['files']) - for piece in info['files']: - length += piece['length'] - - if name and num_files and length: - self.updateTorrent(infohash, notify=False, name=name, num_files=num_files, length=length) - - def on_torrent_collect_response(self, infohashes): - infohash_list = [(bin2str(infohash)) for infohash in infohashes] - - i_parameters = u"?," * len(infohash_list) - i_parameters = i_parameters[:-1] - - sql = u"SELECT torrent_id, infohash FROM Torrent WHERE infohash in (%s)" % i_parameters - results = self._db.fetchall(sql, infohash_list) - - info_dict = {} - for torrent_id, infohash in results: - if infohash: - info_dict[infohash] = torrent_id - - to_be_inserted = [] - for infohash in infohash_list: - if infohash in info_dict: - continue - to_be_inserted.append((infohash,)) - - if len(to_be_inserted) > 0: - sql = u"INSERT OR IGNORE INTO Torrent (infohash) VALUES (?)" - self._db.executemany(sql, to_be_inserted) - - def on_search_response(self, torrents): - status = u'unknown' - - torrents = [(bin2str(torrent[0]), torrent[1], torrent[2], torrent[3], torrent[4][0], - torrent[5]) for torrent in torrents] - infohash = [(torrent[0],) for torrent in torrents] - - sql = u"SELECT torrent_id, infohash, is_collected, name FROM Torrent WHERE infohash == ?" - results = self._db.executemany(sql, infohash) or [] - - infohash_tid = {} - - tid_collected = set() - tid_name = {} - for torrent_id, infohash, is_collected, name in results: - infohash = str(infohash) - - if infohash: - infohash_tid[infohash] = torrent_id - if is_collected: - tid_collected.add(torrent_id) - tid_name[torrent_id] = name - - insert = [] - update = [] - update_infohash = [] - to_be_indexed = [] - for infohash, swarmname, length, nrfiles, category, creation_date in torrents: - tid = infohash_tid.get(infohash, None) - - if tid: # we know this torrent - if tid not in tid_collected and swarmname != tid_name.get(tid, - ''): # if not collected and name not equal then do fullupdate - update.append((swarmname, length, nrfiles, category, creation_date, infohash, status, tid)) - to_be_indexed.append((tid, swarmname)) - - elif infohash and infohash not in infohash_tid: - update_infohash.append((infohash, tid)) - else: - insert.append((swarmname, length, nrfiles, category, creation_date, infohash, status)) - - if len(update) > 0: - sql = u"UPDATE Torrent SET name = ?, length = ?, num_files = ?, category = ?, creation_date = ?," \ - u" infohash = ?, status = ? WHERE torrent_id = ?" - self._db.executemany(sql, update) - - if len(update_infohash) > 0: - sql = u"UPDATE Torrent SET infohash = ? WHERE torrent_id = ?" - self._db.executemany(sql, update_infohash) - - if len(insert) > 0: - sql = u"INSERT INTO Torrent (name, length, num_files, category, creation_date, infohash," \ - u" status) VALUES (?, ?, ?, ?, ?, ?, ?)" - try: - self._db.executemany(sql, insert) - - were_inserted = [(inserted[5],) for inserted in insert] - sql = u"SELECT torrent_id, name FROM Torrent WHERE infohash == ?" - to_be_indexed = to_be_indexed + list(self._db.executemany(sql, were_inserted)) - except: - print_exc() - self._logger.error(u"infohashes: %s", insert) - - for torrent_id, swarmname in to_be_indexed: - self._indexTorrent(torrent_id, swarmname, []) - - def getTorrentCheckRetries(self, torrent_id): - sql = u"SELECT tracker_check_retries FROM Torrent WHERE torrent_id = ?" - result = self._db.fetchone(sql, (torrent_id,)) - return result - - def updateTorrentCheckResult(self, torrent_id, infohash, seeders, leechers, last_check, next_check, status, - retries): - sql = u"UPDATE Torrent SET num_seeders = ?, num_leechers = ?, last_tracker_check = ?, next_tracker_check = ?," \ - u" status = ?, tracker_check_retries = ? WHERE torrent_id = ?" - - self._db.execute_write(sql, (seeders, leechers, last_check, next_check, status, retries, torrent_id)) - - self._logger.debug(u"update result %d/%d for %s/%d", seeders, leechers, bin2str(infohash), torrent_id) - - # notify - self.notifier.notify(NTFY_TORRENTS, NTFY_UPDATE, infohash) - - def addTorrentTrackerMapping(self, torrent_id, tracker): - self.addTorrentTrackerMappingInBatch(torrent_id, [tracker, ]) - - def addTorrentTrackerMappingInBatch(self, torrent_id, tracker_list): - if not tracker_list: - return - - parameters = u"?," * len(tracker_list) - parameters = parameters[:-1] - sql = u"SELECT tracker FROM TrackerInfo WHERE tracker IN (%s)" % parameters - - found_tracker_list = self._db.fetchall(sql, tuple(tracker_list)) - found_tracker_list = [tracker[0] for tracker in found_tracker_list] - - # update tracker info - not_found_tracker_list = [tracker for tracker in tracker_list if tracker not in found_tracker_list] - for tracker in not_found_tracker_list: - if self.session.lm.tracker_manager is not None: - self.session.lm.tracker_manager.add_tracker(tracker) - - # update torrent-tracker mapping - sql = 'INSERT OR IGNORE INTO TorrentTrackerMapping(torrent_id, tracker_id)' \ - + ' VALUES(?, (SELECT tracker_id FROM TrackerInfo WHERE tracker = ?))' - new_mapping_list = [(torrent_id, tracker) for tracker in tracker_list] - if new_mapping_list: - self._db.executemany(sql, new_mapping_list) - - # add trackers into the torrent file if it has been collected - if not self.session.config.get_torrent_store_enabled() or self.session.lm.torrent_store is None: - return - - infohash = self.getInfohash(torrent_id) - if infohash and self.session.has_collected_torrent(infohash): - torrent_data = self.session.get_collected_torrent(infohash) - - try: - tdef = TorrentDef.load_from_memory(torrent_data) - except ValueError: - self._logger.warning("Invalid torrent file when adding trackers to database.") - return - - new_tracker_list = [] - for tracker in tracker_list: - if tdef.get_tracker() and tracker == tdef.get_tracker(): - continue - if tdef.get_tracker_hierarchy() and tracker in tdef.get_tracker_hierarchy(): - continue - if tracker in ('DHT', 'no-DHT'): - continue - tracker = get_uniformed_tracker_url(tracker) - if tracker and [tracker] not in new_tracker_list: - new_tracker_list.append([tracker]) - - if tdef.get_tracker_hierarchy(): - new_tracker_list = tdef.get_tracker_hierarchy() + new_tracker_list - if new_tracker_list: - tdef.set_tracker_hierarchy(new_tracker_list) - # have to use bencode to get around the TorrentDef.is_finalized() check in TorrentDef.encode() - self.session.save_collected_torrent(infohash, bencode(tdef.metainfo)) - - def getTorrentsOnTracker(self, tracker, current_time, limit=30): - sql = """ - SELECT T.infohash - FROM Torrent T, TrackerInfo TI, TorrentTrackerMapping TTM - WHERE TI.tracker = ? - AND TI.tracker_id = TTM.tracker_id AND T.torrent_id = TTM.torrent_id - AND next_tracker_check < ? - ORDER BY next_tracker_check DESC - LIMIT ? - """ - return [str2bin(tinfo[0]) for tinfo in self._db.fetchall(sql, (tracker, current_time, limit))] - - def getTrackerListByTorrentID(self, torrent_id): - sql = 'SELECT TR.tracker FROM TrackerInfo TR, TorrentTrackerMapping MP' \ - + ' WHERE MP.torrent_id = ?' \ - + ' AND TR.tracker_id = MP.tracker_id' - tracker_list = self._db.fetchall(sql, (torrent_id,)) - return [tracker[0] for tracker in tracker_list] - - def getTrackerListByInfohash(self, infohash): - torrent_id = self.getTorrentID(infohash) - return self.getTrackerListByTorrentID(torrent_id) - - def addTrackerInfo(self, tracker, to_notify=True): - self.addTrackerInfoInBatch([tracker, ], to_notify) - - def addTrackerInfoInBatch(self, tracker_list, to_notify=True): - sql = 'INSERT INTO TrackerInfo(tracker) VALUES(?)' - self._db.executemany(sql, [(tracker,) for tracker in tracker_list]) - - if to_notify: - self.notifier.notify(NTFY_TRACKERINFO, NTFY_INSERT, tracker_list) - - def getTrackerInfoList(self): - sql = 'SELECT tracker, last_check, failures, is_alive FROM TrackerInfo' - tracker_info_list = self._db.fetchall(sql) - return tracker_info_list - - def updateTrackerInfo(self, args): - sql = 'UPDATE TrackerInfo SET' \ - + ' last_check = ?, failures = ?, is_alive = ?' \ - + ' WHERE tracker = ?' - self._db.executemany(sql, args) - - def getRecentlyAliveTrackers(self, limit=10): - sql = """ - SELECT DISTINCT tracker FROM TrackerInfo - WHERE is_alive = 1 - AND tracker != 'no-DHT' AND tracker != 'DHT' - ORDER BY last_check DESC LIMIT ? - """ - trackers = self._db.fetchall(sql, (limit,)) - return [tracker[0] for tracker in trackers] - - def getTorrent(self, infohash, keys=None, include_mypref=True): - assert isinstance(infohash, str), "INFOHASH has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, "INFOHASH has invalid length: %d" % len(infohash) - - if keys is None: - keys = deepcopy(self.value_name) - else: - keys = list(keys) - - res = self._db.getOne('Torrent C', keys, infohash=bin2str(infohash)) - - if not res: - return None - torrent = dict(zip(keys, res)) - - torrent['infohash'] = infohash - - if include_mypref: - tid = torrent['C.torrent_id'] - stats = self.mypref_db.getMyPrefStats(tid) - - if stats: - torrent['myDownloadHistory'] = True - torrent['destination_path'] = stats[tid] - else: - torrent['myDownloadHistory'] = False - - return torrent - - def getLibraryTorrents(self, keys): - sql = u"SELECT " + u", ".join(keys) + u""" FROM MyPreference, Torrent LEFT JOIN ChannelTorrents - ON Torrent.torrent_id = ChannelTorrents.torrent_id WHERE destination_path != '' - AND MyPreference.torrent_id = Torrent.torrent_id""" - data = self._db.fetchall(sql) - - fixed = self.__fixTorrents(keys, data) - return fixed - - def __fixTorrents(self, keys, results): - def fix_value(key): - if key in keys: - key_index = keys.index(key) - for i in range(len(results)): - result = list(results[i]) - if result[key_index]: - result[key_index] = str2bin(result[key_index]) - results[i] = result - - fix_value('infohash') - return results - - def getNumberCollectedTorrents(self): - # return self._db.size('CollectedTorrent') - return self._db.getOne('CollectedTorrent', 'count(torrent_id)') - - def getRecentlyCollectedTorrents(self, limit): - sql = u""" - SELECT CT.infohash, CT.num_seeders, CT.num_leechers, T.last_tracker_check, CT.insert_time - FROM Torrent T, CollectedTorrent CT - WHERE CT.torrent_id = T.torrent_id - AND T.secret is not 1 ORDER BY CT.insert_time DESC LIMIT ? - """ - results = self._db.fetchall(sql, (limit,)) - return [[str2bin(result[0]), result[1], result[2], result[3] or 0, result[4]] for result in results] - - def getRecentlyCheckedTorrents(self, limit): - sql = u""" - SELECT T.infohash, T.num_seeders, T.num_leechers, T.last_tracker_check - FROM Torrent T - WHERE T.is_collected = 0 AND T.num_seeders > 1 - AND T.secret is not 1 ORDER BY T.last_tracker_check, T.num_seeders DESC LIMIT ? - """ - results = self._db.fetchall(sql, (limit,)) - return [[str2bin(result[0]), result[1], result[2], result[3] or 0] for result in results] - - def getRandomlyCollectedTorrents(self, insert_time, limit): - sql = u""" - SELECT CT.infohash, CT.num_seeders, CT.num_leechers, T.last_tracker_check - FROM Torrent T, CollectedTorrent CT - WHERE CT.torrent_id = T.torrent_id - AND CT.insert_time < ? - AND T.secret is not 1 ORDER BY RANDOM() DESC LIMIT ? - """ - results = self._db.fetchall(sql, (insert_time, limit)) - return [[str2bin(result[0]), result[1], result[2], result[3] or 0] for result in results] - - def select_torrents_to_collect(self, hashes): - parameters = '?,' * len(hashes) - parameters = parameters[:-1] - - # TODO: bias according to votecast, popular first - - sql = u"SELECT infohash FROM Torrent WHERE is_collected == 0 AND infohash IN (%s)" % parameters - results = self._db.fetchall(sql, map(bin2str, hashes)) - return [str2bin(infohash) for infohash, in results] - - def getTorrentsStats(self): - return self._db.getOne('CollectedTorrent', ['count(torrent_id)', 'sum(length)', 'sum(num_files)']) - - def freeSpace(self, torrents2del): - if self.channelcast_db and self.channelcast_db._channel_id: - sql = U""" - SELECT name, torrent_id, infohash, relevance, - MIN(relevance, 2500) + MIN(500, num_leechers) + 4*MIN(500, num_seeders) - (MAX(0, MIN(500, (%d - creation_date)/86400)) ) AS weight - FROM CollectedTorrent - WHERE torrent_id NOT IN (SELECT torrent_id FROM MyPreference) - AND torrent_id NOT IN (SELECT torrent_id FROM ChannelTorrents WHERE channel_id == %d) - ORDER BY weight - LIMIT %d - """ % (int(time()), self.channelcast_db._channel_id, torrents2del) - else: - sql = u""" - SELECT name, torrent_id, infohash, relevance, - min(relevance,2500) + min(500,num_leechers) + 4*min(500,num_seeders) - (max(0,min(500,(%d-creation_date)/86400)) ) AS weight - FROM CollectedTorrent - WHERE torrent_id NOT IN (SELECT torrent_id FROM MyPreference) - ORDER BY weight - LIMIT %d - """ % (int(time()), torrents2del) - - res_list = self._db.fetchall(sql) - if len(res_list) == 0: - return 0 - - # delete torrents from db - sql_del_torrent = u"UPDATE Torrent SET name = NULL, is_collected = 0 WHERE torrent_id = ?" - # sql_del_pref = "delete from Preference where torrent_id=?" - - tids = [] - for _name, torrent_id, infohash, _relevance, _weight in res_list: - tids.append((torrent_id,)) - self.session.delete_collected_torrent(infohash) - - self._db.executemany(sql_del_torrent, tids) - # self._db.executemany(sql_del_tracker, tids) - deleted = self._db.connection.changes() - # self._db.executemany(sql_del_pref, tids) - - # but keep the infohash in db to maintain consistence with preference db - # torrent_id_infohashes = [(torrent_id,infohash_str,relevance) for torrent_file_name, torrent_id, infohash_str, relevance, weight in res_list] - # sql_insert = "insert into Torrent (torrent_id, infohash, relevance) values (?,?,?)" - # self._db.executemany(sql_insert, torrent_id_infohashes) - - self._logger.info("Erased %d torrents", deleted) - return deleted - - def relevance_score_remote_torrent(self, torrent_name): - """ - Calculate the relevance score of a remote torrent, based on the name and the matchinfo object - of the last torrent from the database. - The algorithm used is the same one as in search_in_local_torrents_db in SqliteCacheDBHandler.py. - """ - if self.latest_matchinfo_torrent is None: - return 0.0 - matchinfo, keywords = self.latest_matchinfo_torrent - - num_phrases, num_cols, num_rows = unpack_from('III', matchinfo) - unpack_str = 'I' * (3 * num_cols * num_phrases) - matchinfo = unpack_from('I' * 9 + unpack_str, matchinfo)[9:] - - score = 0.0 - for phrase_ind in xrange(num_phrases): - rows_with_term = matchinfo[3 * (phrase_ind * num_cols) + 2] - term_freq = torrent_name.lower().count(keywords[phrase_ind]) - - inv_doc_freq = math.log((num_rows - rows_with_term + 0.5) / (rows_with_term + 0.5), 2) - right_side = ((term_freq * (1.2 + 1)) / (term_freq + 1.2)) - - score += inv_doc_freq * right_side - return score - - def search_in_local_torrents_db(self, query, keys=None, first=0, last=None, family_filter=True): - """ - Search in the local database for torrents matching a specific query. This method also assigns a relevance - score to each torrent, based on the name, files and file extensions. - The algorithm is based on BM25. The document length factor is regarded since our "documents" are very small - (often a few keywords). - See https://en.wikipedia.org/wiki/Okapi_BM25 for more information about BM25. - """ - search_results = [] - keys_str = ", ".join(keys) - keywords = split_into_keywords(query, to_filter_stopwords=True) - infohash_index = keys.index('infohash') - - # This query gets torrents matching speciifc keywords. The matchinfo object is also returned. For more - # information about the returned matchinfo parameters, see https://www.sqlite.org/fts3.html#matchinfo. - results = self._db.fetchall("SELECT DISTINCT %s, Matchinfo(FullTextIndex, 'pcnalx') " - "FROM Torrent T, FullTextIndex " - "LEFT OUTER JOIN _ChannelTorrents C ON T.torrent_id = C.torrent_id " - "WHERE t.name IS NOT NULL AND t.torrent_id = FullTextIndex.rowid " - "AND C.deleted_at IS NULL AND FullTextIndex MATCH ? " - "%s" - "LIMIT %i, %i" - % (keys_str, ("AND t.category != 'xxx' " if family_filter else ""), first, - last - first if last else first + 1000), - (" OR ".join(keywords),)) - - for result in results: - result = list(result) # We convert the result to a mutable list since we have to decode the infohash - result[infohash_index] = str2bin(result[infohash_index]) - matchinfo = result[len(keys)] # The matchinfo is the last element in the results tuple - self.latest_matchinfo_torrent = matchinfo, keywords - num_phrases, num_cols, num_rows = unpack_from('III', matchinfo) - - unpack_str = 'I' * (3 * num_cols * num_phrases) - matchinfo = unpack_from('I' * 9 + unpack_str, matchinfo)[9:] - - scores = [] - - for col_ind in xrange(num_cols): - score = 0 - for phrase_ind in xrange(num_phrases): - # Fetch info about the current matching term. This number is fetched from the matchinfo object. - # See https://www.sqlite.org/fts3.html#matchinfo for info about the offset calculation. - base_term_offset = 3 * (col_ind + phrase_ind * num_cols) - rows_with_term = matchinfo[base_term_offset + 2] - term_freq = matchinfo[base_term_offset] - - inv_doc_freq = math.log((num_rows - rows_with_term + 0.5) / (rows_with_term + 0.5), 2) - right_side = ((term_freq * (1.2 + 1)) / (term_freq + 1.2)) - - score += inv_doc_freq * right_side - - scores.append(score) - - # Our score is 80% dependent on matching in the name of the torrent, 10% on the names of the files in the - # torrent and 10% on the extensions of files in the torrent. - rel_score = 0.8 * scores[0] + 0.1 * scores[1] + 0.1 * scores[2] - if 'num_seeders' in keys and result[keys.index('num_seeders')] > 0: - # If this torrent has a non-zero amount of seeders, we make it more relevant - rel_score += result[keys.index('num_seeders')] - - extended_result = result + [rel_score] - search_results.append(extended_result) - - return search_results - - def searchNames(self, kws, local=True, keys=None, doSort=True): - assert 'infohash' in keys - assert not doSort or ('num_seeders' in keys or 'T.num_seeders' in keys) - - infohash_index = keys.index('infohash') - num_seeders_index = keys.index('num_seeders') if 'num_seeders' in keys else -1 - - if num_seeders_index == -1: - doSort = False - - values = ", ".join(keys) - mainsql = "SELECT " + values + ", C.channel_id, Matchinfo(FullTextIndex) FROM" - if local: - mainsql += " Torrent T" - else: - mainsql += " CollectedTorrent T" - - mainsql += """, FullTextIndex - LEFT OUTER JOIN _ChannelTorrents C ON T.torrent_id = C.torrent_id - WHERE t.name IS NOT NULL AND t.torrent_id = FullTextIndex.rowid AND C.deleted_at IS NULL AND FullTextIndex MATCH ? - """ - - if not local: - mainsql += "AND T.secret is not 1 LIMIT 250" - - query = " ".join(filter_keywords(kws)) - not_negated = [kw for kw in filter_keywords(kws) if kw[0] != '-'] - - results = self._db.fetchall(mainsql, (query,)) - - channels = set() - channel_dict = {} - for result in results: - if result[-2]: - channels.add(result[-2]) - - if len(channels) > 0: - # results are tuples of (id, str(dispersy_cid), name, description, - # nr_torrents, nr_favorites, nr_spam, my_vote, modified, id == - # self._channel_id) - for channel in self.channelcast_db.getChannels(channels): - if channel[1] != '-1': - channel_dict[channel[0]] = channel - - myChannelId = self.channelcast_db._channel_id or 0 - - result_dict = {} - - # step 1, merge torrents keep one with best channel - for result in results: - channel_id = result[-2] - channel = channel_dict.get(channel_id, None) - - infohash = result[infohash_index] - if channel: - # ignoring spam channels - if channel[7] < 0: - continue - - # see if we have a better channel in torrents_dict - if infohash in result_dict: - old_channel = channel_dict.get(result_dict[infohash][-2], False) - if old_channel: - - # allways prefer my channel - if old_channel[0] == myChannelId: - continue - - # allways prefer channel with higher vote - if channel[7] < old_channel[7]: - continue - - votes = (channel[5] or 0) - (channel[6] or 0) - oldvotes = (old_channel[5] or 0) - (old_channel[6] or 0) - if votes < oldvotes: - continue - - result_dict[infohash] = result - - elif infohash not in result_dict: - result_dict[infohash] = result - - # step 2, fix all dict fields - dont_sort_list = [] - results = [list(result) for result in result_dict.values()] - for index in xrange(len(results) - 1, -1, -1): - result = results[index] - - result[infohash_index] = str2bin(result[infohash_index]) - - matches = {'swarmname': set(), 'filenames': set(), 'fileextensions': set()} - - # Matchinfo is documented at: http://www.sqlite.org/fts3.html#matchinfo - matchinfo = str(result[-1]) - num_phrases, num_cols = unpack_from('II', matchinfo) - unpack_str = 'I' * (3 * num_cols * num_phrases) - matchinfo = unpack_from('II' + unpack_str, matchinfo) - - swarmnames, filenames, fileextensions = [ - [matchinfo[3 * (i + p * num_cols) + 2] for p in range(num_phrases)] - for i in range(num_cols) - ] - - for i, keyword in enumerate(not_negated): - if swarmnames[i]: - matches['swarmname'].add(keyword) - if filenames[i]: - matches['filenames'].add(keyword) - if fileextensions[i]: - matches['fileextensions'].add(keyword) - result[-1] = matches - - channel = channel_dict.get(result[-2], (result[-2], None, '', '', 0, 0, 0, 0, 0, False)) - result.extend(channel) - - if doSort and result[num_seeders_index] <= 0: - dont_sort_list.append((index, result)) - - if doSort: - # Remove the items with 0 seeders from the results list so the sort is faster, append them to the - # results list afterwards. - for index, result in dont_sort_list: - results.pop(index) - - def compare(a, b): - return cmp(a[num_seeders_index], b[num_seeders_index]) - - results.sort(compare, reverse=True) - - for index, result in dont_sort_list: - results.append(result) - - if not local: - results = results[:25] - - return results - - def getAutoCompleteTerms(self, keyword, max_terms, limit=100): - sql = "SELECT swarmname FROM FullTextIndex WHERE swarmname MATCH ? LIMIT ?" - result = self._db.fetchall(sql, ('"%s*"' % keyword, limit)) - - all_terms = set() - for line, in result: - if len(all_terms) >= max_terms: - break - i1 = line.find(keyword) - i2 = line.find(' ', i1 + len(keyword)) - all_terms.add(line[i1:i2] if i2 >= 0 else line[i1:]) - - if keyword in all_terms: - all_terms.remove(keyword) - if '' in all_terms: - all_terms.remove('') - - return list(all_terms) - - def getSearchSuggestion(self, keywords, limit=1): - match = [keyword.lower() for keyword in keywords if len(keyword) > 3] - - def lev(a, b): - "Calculates the Levenshtein distance between a and b." - n, m = len(a), len(b) - if n > m: - # Make sure n <= m, to use O(min(n,m)) space - a, b = b, a - n, m = m, n - - current = range(n + 1) - for i in range(1, m + 1): - previous, current = current, [i] + [0] * n - for j in range(1, n + 1): - add, delete = previous[j] + 1, current[j - 1] + 1 - change = previous[j - 1] - if a[j - 1] != b[i - 1]: - change = change + 1 - current[j] = min(add, delete, change) - - return current[n] - - def levcollate(s1, s2): - l1 = sum(sorted([lev(a, b) for a in s1.split() for b in match])[:len(match)]) - l2 = sum(sorted([lev(a, b) for a in s2.split() for b in match])[:len(match)]) - - # return -1 if s1s2 else 0 - if l1 < l2: - return -1 - if l1 > l2: - return 1 - return 0 - - cursor = self._db.get_cursor() - connection = cursor.getconnection() - connection.createcollation("leven", levcollate) - - sql = "SELECT swarmname FROM FullTextIndex WHERE swarmname MATCH ? ORDER By swarmname collate leven ASC LIMIT ?" - results = self._db.fetchall(sql, (' OR '.join(['*%s*' % m for m in match]), limit)) - connection.createcollation("leven", None) - return [result[0] for result in results] - - -class MyPreferenceDBHandler(BasicDBHandler): - - def __init__(self, session): - super(MyPreferenceDBHandler, self).__init__(session, u"MyPreference") - - self.rlock = threading.RLock() - - self.recent_preflist = None - self._torrent_db = None - - def initialize(self, *args, **kwargs): - self._torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - - def close(self): - super(MyPreferenceDBHandler, self).close() - self._torrent_db = None - - def getMyPrefListInfohash(self, returnDeleted=True, limit=None): - # Arno, 2012-08-01: having MyPreference (the shorter list) first makes - # this faster. - sql = u"SELECT infohash FROM MyPreference, Torrent WHERE Torrent.torrent_id == MyPreference.torrent_id" - if not returnDeleted: - sql += u' AND destination_path != ""' - - if limit: - sql += u" ORDER BY creation_time DESC LIMIT %d" % limit - - res = self._db.fetchall(sql) - res = [item for sublist in res for item in sublist] - return [str2bin(p) if p else '' for p in res] - - def getMyPrefStats(self, torrent_id=None): - value_name = ('torrent_id', 'destination_path',) - if torrent_id is not None: - where = 'torrent_id == %s' % torrent_id - else: - where = None - res = self.getAll(value_name, where) - mypref_stats = {} - for torrent_id, destination_path in res: - mypref_stats[torrent_id] = destination_path - return mypref_stats - - def getMyPrefStatsInfohash(self, infohash): - torrent_id = self._torrent_db.getTorrentID(infohash) - if torrent_id is not None: - return self.getMyPrefStats(torrent_id)[torrent_id] - - def addMyPreference(self, torrent_id, data): - # keys in data: destination_path, creation_time, torrent_id - if self.getOne('torrent_id', torrent_id=torrent_id) is not None: - # Arno, 2009-03-09: Torrent already exists in myrefs. - # Hack for hiding from lib while keeping in myprefs. - # see standardOverview.removeTorrentFromLibrary() - # - self.updateDestDir(torrent_id, data.get('destination_path')) - infohash = self._torrent_db.getInfohash(torrent_id) - if infohash: - self.notifier.notify(NTFY_MYPREFERENCES, NTFY_UPDATE, infohash) - return False - - d = {} - d['destination_path'] = data.get('destination_path') - d['creation_time'] = data.get('creation_time', int(time())) - d['torrent_id'] = torrent_id - - self._db.insert(self.table_name, **d) - - infohash = self._torrent_db.getInfohash(torrent_id) - if infohash: - self.notifier.notify(NTFY_MYPREFERENCES, NTFY_INSERT, infohash) - - return True - - def deletePreference(self, torrent_id): - # Preferences are never actually deleted from the database, only their destdirs get reset. - # self._db.delete(self.table_name, **{'torrent_id': torrent_id}) - self.updateDestDir(torrent_id, "") - - infohash = self._torrent_db.getInfohash(torrent_id) - if infohash: - self.notifier.notify(NTFY_MYPREFERENCES, NTFY_DELETE, infohash) - - def updateDestDir(self, torrent_id, destdir): - if not isinstance(destdir, basestring): - self._logger.info('DESTDIR IS NOT STRING: %s', destdir) - return - self._db.update(self.table_name, 'torrent_id=%d' % torrent_id, destination_path=destdir) - - -class VoteCastDBHandler(BasicDBHandler): - - def __init__(self, session): - super(VoteCastDBHandler, self).__init__(session, u"VoteCast") - - self.my_votes = None - self.updatedChannels = set() - - self.channelcast_db = None - - def initialize(self, *args, **kwargs): - self.channelcast_db = self.session.open_dbhandler(NTFY_CHANNELCAST) - self.session.sqlite_db.register_task(u"flush to database", - LoopingCall(self._flush_to_database)).start(VOTECAST_FLUSH_DB_INTERVAL, - now=False) - - def close(self): - super(VoteCastDBHandler, self).close() - self.channelcast_db = None - - def on_votes_from_dispersy(self, votes): - insert_vote = "INSERT OR REPLACE INTO _ChannelVotes (channel_id, voter_id, dispersy_id, vote, time_stamp) VALUES (?,?,?,?,?)" - self._db.executemany(insert_vote, votes) - - for channel_id, voter_id, _, vote, _ in votes: - if voter_id is None: - self.notifier.notify(NTFY_VOTECAST, NTFY_UPDATE, channel_id, voter_id is None) - if self.my_votes is not None: - self.my_votes[channel_id] = vote - self.updatedChannels.add(channel_id) - - def on_remove_votes_from_dispersy(self, votes, contains_my_vote): - remove_vote = "UPDATE _ChannelVotes SET deleted_at = ? WHERE channel_id = ? AND dispersy_id = ?" - self._db.executemany(remove_vote, votes) - - if contains_my_vote: - for _, channel_id, _ in votes: - self.notifier.notify(NTFY_VOTECAST, NTFY_UPDATE, channel_id, contains_my_vote) - - for _, channel_id, _ in votes: - self.updatedChannels.add(channel_id) - - def _flush_to_database(self): - channel_ids = list(self.updatedChannels) - self.updatedChannels.clear() - - if channel_ids: - parameters = ",".join("?" * len(channel_ids)) - sql = "Select channel_id, vote FROM ChannelVotes WHERE channel_id in (" + parameters + ")" - positive_votes = {} - negative_votes = {} - for channel_id, vote in self._db.fetchall(sql, channel_ids): - if vote == 2: - positive_votes[channel_id] = positive_votes.get(channel_id, 0) + 1 - elif vote == -1: - negative_votes[channel_id] = negative_votes.get(channel_id, 0) + 1 - - updates = [(positive_votes.get(channel_id, 0), negative_votes.get(channel_id, 0), channel_id) - for channel_id in channel_ids] - self._db.executemany("UPDATE OR IGNORE _Channels SET nr_favorite = ?, nr_spam = ? WHERE id = ?", updates) - - for channel_id in channel_ids: - self.notifier.notify(NTFY_VOTECAST, NTFY_UPDATE, channel_id) - - def get_latest_vote_dispersy_id(self, channel_id, voter_id): - if voter_id: - select_vote = """SELECT dispersy_id FROM ChannelVotes - WHERE channel_id = ? AND voter_id = ? AND dispersy_id != -1 - ORDER BY time_stamp DESC Limit 1""" - return self._db.fetchone(select_vote, (channel_id, voter_id)) - - select_vote = """SELECT dispersy_id FROM ChannelVotes - WHERE channel_id = ? AND voter_id ISNULL AND dispersy_id != -1 - ORDER BY time_stamp DESC Limit 1""" - return self._db.fetchone(select_vote, (channel_id,)) - - def getPosNegVotes(self, channel_id): - sql = 'select nr_favorite, nr_spam from Channels where id = ?' - result = self._db.fetchone(sql, (channel_id,)) - if result: - return result - return 0, 0 - - def getVoteOnChannel(self, channel_id, voter_id): - """ return the vote status if such record exists, otherwise None """ - if voter_id: - sql = "select vote from ChannelVotes where channel_id = ? and voter_id = ?" - return self._db.fetchone(sql, (channel_id, voter_id)) - sql = "select vote from ChannelVotes where channel_id = ? and voter_id ISNULL" - return self._db.fetchone(sql, (channel_id,)) - - def getVoteForMyChannel(self, voter_id): - return self.getVoteOnChannel(self.channelcast_db._channel_id, voter_id) - - def getDispersyId(self, channel_id, voter_id): - """ return the dispersy_id for this vote """ - if voter_id: - sql = "select dispersy_id from ChannelVotes where channel_id = ? and voter_id = ?" - return self._db.fetchone(sql, (channel_id, voter_id)) - sql = "select dispersy_id from ChannelVotes where channel_id = ? and voter_id ISNULL" - return self._db.fetchone(sql, (channel_id,)) - - def getTimestamp(self, channel_id, voter_id): - """ return the timestamp for this vote """ - if voter_id: - sql = "select time_stamp from ChannelVotes where channel_id = ? and voter_id = ?" - return self._db.fetchone(sql, (channel_id, voter_id)) - sql = "select time_stamp from ChannelVotes where channel_id = ? and voter_id ISNULL" - return self._db.fetchone(sql, (channel_id,)) - - def getMyVotes(self): - if not self.my_votes: - sql = "SELECT channel_id, vote FROM ChannelVotes WHERE voter_id ISNULL" - - self.my_votes = {} - for channel_id, vote in self._db.fetchall(sql): - self.my_votes[channel_id] = vote - return self.my_votes - - -class ChannelCastDBHandler(BasicDBHandler): - - def __init__(self, session): - super(ChannelCastDBHandler, self).__init__(session, u"_Channels") - - self._channel_id = None - self.my_dispersy_cid = None - - self.votecast_db = None - self.torrent_db = None - - def initialize(self, *args, **kwargs): - self._channel_id = self.getMyChannelId() - self._logger.debug(u"Channels: my channel is %s", self._channel_id) - - self.votecast_db = self.session.open_dbhandler(NTFY_VOTECAST) - self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - - def update_nr_torrents(): - rows = self.getChannelNrTorrents(50) - update = "UPDATE _Channels SET nr_torrents = ? WHERE id = ?" - self._db.executemany(update, rows) - - rows = self.getChannelNrTorrentsLatestUpdate(50) - update = "UPDATE _Channels SET nr_torrents = ?, modified = ? WHERE id = ?" - self._db.executemany(update, rows) - - self.register_task(u"update_nr_torrents", LoopingCall(update_nr_torrents)).start(300, now=False) - - def close(self): - super(ChannelCastDBHandler, self).close() - self._channel_id = None - self.my_dispersy_cid = None - - self.votecast_db = None - self.torrent_db = None - - def get_metadata_torrents(self, is_collected=True, limit=20): - stmt = u""" -SELECT T.torrent_id, T.infohash, T.name, T.length, T.category, T.status, T.num_seeders, T.num_leechers, CMD.value -FROM MetaDataTorrent, ChannelTorrents AS CT, ChannelMetaData AS CMD, Torrent AS T -WHERE CT.id == MetaDataTorrent.channeltorrent_id - AND CMD.id == MetaDataTorrent.metadata_id - AND T.torrent_id == CT.torrent_id - AND CMD.type == 'metadata-json' - AND CMD.value LIKE '%thumb_hash%' - AND T.is_collected == ? -ORDER BY CMD.time_stamp DESC LIMIT ?; -""" - result_list = self._db.fetchall(stmt, (int(is_collected), limit)) or [] - torrent_list = [] - for torrent_id, info_hash, name, length, category, status, num_seeders, num_leechers, metadata_json in result_list: - torrent_dict = {'id': torrent_id, - 'info_hash': str2bin(info_hash), - 'name': name, - 'length': length, - 'category': category, - 'status': status, - 'num_seeders': num_seeders, - 'num_leechers': num_leechers, - 'metadata-json': metadata_json} - torrent_list.append(torrent_dict) - - return torrent_list - - # dispersy helper functions - def _get_my_dispersy_cid(self): - if not self.my_dispersy_cid: - from Tribler.community.channel.community import ChannelCommunity - - for community in self.session.lm.dispersy.get_communities(): - if isinstance(community, - ChannelCommunity) and community.master_member and community.master_member.private_key: - self.my_dispersy_cid = community.cid - break - - return self.my_dispersy_cid - - def get_torrent_metadata(self, channel_torrent_id): - stmt = u"""SELECT ChannelMetadata.value FROM ChannelMetadata, MetaDataTorrent - WHERE type = 'metadata-json' - AND ChannelMetadata.id = MetaDataTorrent.metadata_id - AND MetaDataTorrent.channeltorrent_id = ?""" - result = self._db.fetchone(stmt, (channel_torrent_id,)) - if result: - metadata_dict = json.loads(result) - metadata_dict['thumb_hash'] = metadata_dict['thumb_hash'].decode('hex') - return metadata_dict - - def getDispersyCIDFromChannelId(self, channel_id): - return self._db.fetchone(u"SELECT dispersy_cid FROM Channels WHERE id = ?", (channel_id,)) - - def getChannelIdFromDispersyCID(self, dispersy_cid): - return self._db.fetchone(u"SELECT id FROM Channels WHERE dispersy_cid = ?", (dispersy_cid,)) - - def getCountMaxFromChannelId(self, channel_id): - sql = u"SELECT COUNT(*), MAX(inserted) FROM ChannelTorrents WHERE channel_id = ? LIMIT 1" - return self._db.fetchone(sql, (channel_id,)) - - def on_channel_from_dispersy(self, dispersy_cid, peer_id, name, description): - if isinstance(dispersy_cid, (str)): - _dispersy_cid = buffer(dispersy_cid) - else: - _dispersy_cid = dispersy_cid - - # merge channels if we detect upgrade from old-channelcast to new-dispersy-channelcast - get_channel = "SELECT id FROM Channels Where peer_id = ? and dispersy_cid == -1" - channel_id = self._db.fetchone(get_channel, (peer_id,)) - - if channel_id: # update this channel - update_channel = "UPDATE _Channels SET dispersy_cid = ?, name = ?, description = ? WHERE id = ?" - self._db.execute_write(update_channel, (_dispersy_cid, name, description, channel_id)) - - self.notifier.notify(NTFY_CHANNELCAST, NTFY_UPDATE, channel_id) - - else: - get_channel = "SELECT id FROM Channels Where dispersy_cid = ?" - channel_id = self._db.fetchone(get_channel, (_dispersy_cid,)) - - if channel_id: - update_channel = "UPDATE _Channels SET name = ?, description = ?, peer_id = ? WHERE dispersy_cid = ?" - self._db.execute_write(update_channel, (name, description, peer_id, _dispersy_cid)) - - else: - # insert channel - insert_channel = "INSERT INTO _Channels (dispersy_cid, peer_id, name, description) VALUES (?, ?, ?, ?); SELECT last_insert_rowid();" - channel_id = self._db.fetchone(insert_channel, (_dispersy_cid, peer_id, name, description)) - - self.notifier.notify(NTFY_CHANNELCAST, NTFY_INSERT, channel_id) - - if not self._channel_id and self._get_my_dispersy_cid() == dispersy_cid: - self._channel_id = channel_id - self.notifier.notify(NTFY_CHANNELCAST, NTFY_CREATE, channel_id) - return channel_id - - def on_channel_modification_from_dispersy(self, channel_id, modification_type, modification_value): - if modification_type in ['name', 'description']: - update_channel = "UPDATE _Channels Set " + modification_type + " = ?, modified = ? WHERE id = ?" - self._db.execute_write(update_channel, (modification_value, long(time()), channel_id)) - - self.notifier.notify(NTFY_CHANNELCAST, NTFY_MODIFIED, channel_id) - - def on_torrents_from_dispersy(self, torrentlist): - infohashes = [torrent[3] for torrent in torrentlist] - torrent_ids, inserted = self.torrent_db.addOrGetTorrentIDSReturn(infohashes) - - insert_data = [] - updated_channels = {} - - for i, torrent in enumerate(torrentlist): - channel_id, dispersy_id, peer_id, infohash, timestamp, name, files, trackers = torrent - torrent_id = torrent_ids[i] - - # if new or not yet collected - if infohash in inserted: - self.torrent_db.addExternalTorrentNoDef( - infohash, name, files, trackers, timestamp, {'dispersy_id': dispersy_id}) - - insert_data.append((dispersy_id, torrent_id, channel_id, peer_id, name, timestamp)) - updated_channels[channel_id] = updated_channels.get(channel_id, 0) + 1 - - if len(insert_data) > 0: - sql_insert_torrent = "INSERT INTO _ChannelTorrents (dispersy_id, torrent_id, channel_id, peer_id, name, time_stamp) VALUES (?,?,?,?,?,?)" - self._db.executemany(sql_insert_torrent, insert_data) - - updated_channel_torrent_dict = defaultdict(list) - for torrent in torrentlist: - channel_id, dispersy_id, peer_id, infohash, timestamp, name, files, trackers = torrent - channel_torrent_id = self.get_channel_torrent_id(channel_id, infohash) - updated_channel_torrent_dict[channel_id].append({u'info_hash': infohash, - u'channel_torrent_id': channel_torrent_id}) - - sql_update_channel = "UPDATE _Channels SET modified = strftime('%s','now'), nr_torrents = nr_torrents+? WHERE id = ?" - update_channels = [(new_torrents, channel_id) for channel_id, new_torrents in updated_channels.iteritems()] - self._db.executemany(sql_update_channel, update_channels) - - for channel_id in updated_channels.keys(): - self.notifier.notify(NTFY_CHANNELCAST, NTFY_UPDATE, channel_id) - - for channel_id, item in updated_channel_torrent_dict.items(): - # inform the channel_manager about new channel torrents - self.notifier.notify(SIGNAL_CHANNEL_COMMUNITY, SIGNAL_ON_TORRENT_UPDATED, channel_id, item) - - def on_remove_torrent_from_dispersy(self, channel_id, dispersy_id, redo): - sql = "UPDATE _ChannelTorrents SET deleted_at = ? WHERE channel_id = ? and dispersy_id = ?" - - if redo: - deleted_at = None - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, channel_id, dispersy_id)) - - self.notifier.notify(NTFY_CHANNELCAST, NTFY_UPDATE, channel_id) - - sql = """SELECT infohash, dispersy_cid FROM Torrent, _ChannelTorrents, Channels - WHERE Torrent.torrent_id = _ChannelTorrents.torrent_id - AND _ChannelTorrents.channel_id = ? AND _ChannelTorrents.dispersy_id = ? - AND Channels.id = _ChannelTorrents.channel_id""" - infohash, dispersy_cid = self._db.fetchone(sql, (channel_id, dispersy_id)) - - if infohash: - self.notifier.notify(NTFY_TORRENTS, NTFY_DELETE, None, - {"infohash": str2bin(infohash).encode('hex'), - "dispersy_cid": str(dispersy_cid).encode('hex')}) - - def on_torrent_modification_from_dispersy(self, channeltorrent_id, modification_type, modification_value): - if modification_type in ['name', 'description']: - update_torrent = "UPDATE _ChannelTorrents SET " + modification_type + " = ?, modified = ? WHERE id = ?" - self._db.execute_write(update_torrent, (modification_value, long(time()), channeltorrent_id)) - - sql = "Select infohash From Torrent, ChannelTorrents Where Torrent.torrent_id = ChannelTorrents.torrent_id And ChannelTorrents.id = ?" - infohash = self._db.fetchone(sql, (channeltorrent_id,)) - - if infohash: - infohash = str2bin(infohash) - self.notifier.notify(NTFY_TORRENTS, NTFY_UPDATE, infohash) - - def addOrGetChannelTorrentID(self, channel_id, infohash): - torrent_id = self.torrent_db.addOrGetTorrentID(infohash) - - sql = "SELECT id FROM _ChannelTorrents WHERE torrent_id = ? AND channel_id = ?" - channeltorrent_id = self._db.fetchone(sql, (torrent_id, channel_id)) - if not channeltorrent_id: - insert_torrent = "INSERT OR IGNORE INTO _ChannelTorrents (dispersy_id, torrent_id, channel_id, time_stamp) VALUES (?,?,?,?);" - self._db.execute_write(insert_torrent, (-1, torrent_id, channel_id, -1)) - - channeltorrent_id = self._db.fetchone(sql, (torrent_id, channel_id)) - return channeltorrent_id - - def get_channel_torrent_id(self, channel_id, info_hash): - torrent_id = self.torrent_db.getTorrentID(info_hash) - if torrent_id: - sql = "SELECT id FROM ChannelTorrents WHERE torrent_id = ? and channel_id = ?" - channeltorrent_id = self._db.fetchone(sql, (torrent_id, channel_id)) - return channeltorrent_id - - def hasTorrent(self, channel_id, infohash): - return True if self.get_channel_torrent_id(channel_id, infohash) else False - - def hasTorrents(self, channel_id, infohashes): - returnAr = [] - torrent_id_results = self.torrent_db.getTorrentIDS(infohashes) - - for infohash in infohashes: - if torrent_id_results[infohash] is None: - returnAr.append(False) - else: - torrent_id = torrent_id_results[infohash] - sql = "SELECT id FROM ChannelTorrents WHERE torrent_id = ? AND channel_id = ? AND dispersy_id <> -1" - channeltorrent_id = self._db.fetchone(sql, (torrent_id, channel_id)) - returnAr.append(True if channeltorrent_id else False) - return returnAr - - def playlistHasTorrent(self, playlist_id, channeltorrent_id): - sql = "SELECT id FROM PlaylistTorrents WHERE playlist_id = ? AND channeltorrent_id = ?" - playlisttorrent_id = self._db.fetchone(sql, (playlist_id, channeltorrent_id)) - if playlisttorrent_id: - return True - return False - - # dispersy receiving comments - def on_comment_from_dispersy(self, channel_id, dispersy_id, mid_global_time, peer_id, comment, timestamp, - reply_to, reply_after, playlist_dispersy_id, infohash): - # both reply_to and reply_after could be loose pointers to not yet received dispersy message - if isinstance(reply_to, (str)): - reply_to = buffer(reply_to) - - if isinstance(reply_after, (str)): - reply_after = buffer(reply_after) - mid_global_time = buffer(mid_global_time) - - sql = """INSERT OR REPLACE INTO _Comments - (channel_id, dispersy_id, peer_id, comment, reply_to_id, reply_after_id, time_stamp) - VALUES (?, ?, ?, ?, ?, ?, ?); SELECT last_insert_rowid();""" - comment_id = self._db.fetchone( - sql, (channel_id, dispersy_id, peer_id, comment, reply_to, reply_after, timestamp)) - - if playlist_dispersy_id or infohash: - if playlist_dispersy_id: - sql = "SELECT id FROM Playlists WHERE dispersy_id = ?" - playlist_id = self._db.fetchone(sql, (playlist_dispersy_id,)) - - sql = "INSERT INTO CommentPlaylist (comment_id, playlist_id) VALUES (?, ?)" - self._db.execute_write(sql, (comment_id, playlist_id)) - - if infohash: - channeltorrent_id = self.addOrGetChannelTorrentID(channel_id, infohash) - - sql = "INSERT INTO CommentTorrent (comment_id, channeltorrent_id) VALUES (?, ?)" - self._db.execute_write(sql, (comment_id, channeltorrent_id)) - - # try fo fix loose reply_to and reply_after pointers - sql = "UPDATE _Comments SET reply_to_id = ? WHERE reply_to_id = ?" - self._db.execute_write(sql, (dispersy_id, mid_global_time)) - sql = "UPDATE _Comments SET reply_after_id = ? WHERE reply_after_id = ?" - self._db.execute_write(sql, (dispersy_id, mid_global_time)) - - self.notifier.notify(NTFY_COMMENTS, NTFY_INSERT, channel_id) - if playlist_dispersy_id: - self.notifier.notify(NTFY_COMMENTS, NTFY_INSERT, playlist_id) - if infohash: - self.notifier.notify(NTFY_COMMENTS, NTFY_INSERT, infohash) - - # dispersy removing comments - def on_remove_comment_from_dispersy(self, channel_id, dispersy_id, infohash=None, redo=False): - sql = "UPDATE _Comments SET deleted_at = ? WHERE dispersy_id = ?" - - if redo: - deleted_at = None - self._db.execute_write(sql, (deleted_at, dispersy_id)) - - self.notifier.notify(NTFY_COMMENTS, NTFY_INSERT, channel_id) - if infohash: - self.notifier.notify(NTFY_COMMENTS, NTFY_INSERT, infohash) - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, dispersy_id)) - - self.notifier.notify(NTFY_COMMENTS, NTFY_DELETE, channel_id) - if infohash: - self.notifier.notify(NTFY_COMMENTS, NTFY_DELETE, infohash) - - # dispersy receiving, modifying playlists - def on_playlist_from_dispersy(self, channel_id, dispersy_id, peer_id, name, description): - sql = "INSERT OR REPLACE INTO _Playlists (channel_id, dispersy_id, peer_id, name, description) VALUES (?, ?, ?, ?, ?)" - self._db.execute_write(sql, (channel_id, dispersy_id, peer_id, name, description)) - - self.notifier.notify(NTFY_PLAYLISTS, NTFY_INSERT, channel_id) - - def on_remove_playlist_from_dispersy(self, channel_id, dispersy_id, redo): - sql = "UPDATE _Playlists SET deleted_at = ? WHERE channel_id = ? and dispersy_id = ?" - - if redo: - deleted_at = None - self._db.execute_write(sql, (deleted_at, channel_id, dispersy_id)) - self.notifier.notify(NTFY_PLAYLISTS, NTFY_INSERT, channel_id) - - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, channel_id, dispersy_id)) - self.notifier.notify(NTFY_PLAYLISTS, NTFY_DELETE, channel_id) - - def on_playlist_modification_from_dispersy(self, playlist_id, modification_type, modification_value): - if modification_type in ['name', 'description']: - update_playlist = "UPDATE _Playlists Set " + modification_type + " = ?, modified = ? WHERE id = ?" - self._db.execute_write(update_playlist, (modification_value, long(time()), playlist_id)) - - self.notifier.notify(NTFY_PLAYLISTS, NTFY_UPDATE, playlist_id) - - def on_playlist_torrent(self, dispersy_id, playlist_dispersy_id, peer_id, infohash): - get_playlist = "SELECT id, channel_id FROM _Playlists WHERE dispersy_id = ?" - playlist_id, channel_id = self._db.fetchone(get_playlist, (playlist_dispersy_id,)) - - channeltorrent_id = self.addOrGetChannelTorrentID(channel_id, infohash) - sql = "INSERT INTO _PlaylistTorrents (dispersy_id, playlist_id, peer_id, channeltorrent_id) VALUES (?,?,?,?)" - self._db.execute_write(sql, (dispersy_id, playlist_id, peer_id, channeltorrent_id)) - - self.notifier.notify(NTFY_PLAYLISTS, NTFY_UPDATE, playlist_id, infohash) - - def on_remove_playlist_torrent(self, channel_id, playlist_dispersy_id, infohash, redo): - get_playlist = "SELECT id FROM _Playlists WHERE dispersy_id = ? AND channel_id = ?" - playlist_id = self._db.fetchone(get_playlist, (playlist_dispersy_id, channel_id)) - - if playlist_id: - get_channeltorent_id = """SELECT _ChannelTorrents.id FROM _ChannelTorrents, Torrent, _PlaylistTorrents - WHERE _ChannelTorrents.torrent_id = Torrent.torrent_id AND _ChannelTorrents.id = - _PlaylistTorrents.channeltorrent_id AND playlist_id = ? AND Torrent.infohash = ?""" - channeltorrent_id = self._db.fetchone(get_channeltorent_id, (playlist_id, bin2str(infohash))) - - if channeltorrent_id: - sql = "UPDATE _PlaylistTorrents SET deleted_at = ? WHERE playlist_id = ? AND channeltorrent_id = ?" - - if redo: - deleted_at = None - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, playlist_id, channeltorrent_id)) - - self.notifier.notify(NTFY_PLAYLISTS, NTFY_UPDATE, playlist_id) - - def on_metadata_from_dispersy(self, type, channeltorrent_id, playlist_id, channel_id, dispersy_id, peer_id, - mid_global_time, modification_type, modification_value, timestamp, - prev_modification_id, prev_modification_global_time): - if isinstance(prev_modification_id, (str)): - prev_modification_id = buffer(prev_modification_id) - - sql = """INSERT OR REPLACE INTO _ChannelMetaData - (dispersy_id, channel_id, peer_id, type, value, time_stamp, prev_modification, prev_global_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?); SELECT last_insert_rowid();""" - metadata_id = self._db.fetchone(sql, (dispersy_id, channel_id, peer_id, - modification_type, - modification_value, timestamp, - prev_modification_id, - prev_modification_global_time)) - - if channeltorrent_id: - sql = "INSERT INTO MetaDataTorrent (metadata_id, channeltorrent_id) VALUES (?,?)" - self._db.execute_write(sql, (metadata_id, channeltorrent_id)) - - self.notifier.notify(NTFY_MODIFICATIONS, NTFY_INSERT, channeltorrent_id) - - if playlist_id: - sql = "INSERT INTO MetaDataPlaylist (metadata_id, playlist_id) VALUES (?,?)" - self._db.execute_write(sql, (metadata_id, playlist_id)) - - self.notifier.notify(NTFY_MODIFICATIONS, NTFY_INSERT, playlist_id) - self.notifier.notify(NTFY_MODIFICATIONS, NTFY_INSERT, channel_id) - - # try fo fix loose reply_to and reply_after pointers - sql = "UPDATE _ChannelMetaData SET prev_modification = ? WHERE prev_modification = ?;" - self._db.execute_write(sql, (dispersy_id, buffer(mid_global_time))) - - def on_remove_metadata_from_dispersy(self, channel_id, dispersy_id, redo): - sql = "UPDATE _ChannelMetaData SET deleted_at = ? WHERE dispersy_id = ? AND channel_id = ?" - - if redo: - deleted_at = None - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, dispersy_id, channel_id)) - - def on_moderation(self, channel_id, dispersy_id, peer_id, by_peer_id, cause, message, timestamp, severity): - sql = """INSERT OR REPLACE INTO _Moderations - (dispersy_id, channel_id, peer_id, by_peer_id, message, cause, time_stamp, severity) - VALUES (?,?,?,?,?,?,?,?)""" - self._db.execute_write(sql, (dispersy_id, channel_id, peer_id, by_peer_id, message, cause, timestamp, severity)) - - self.notifier.notify(NTFY_MODERATIONS, NTFY_INSERT, channel_id) - - def on_remove_moderation(self, channel_id, dispersy_id, redo): - sql = "UPDATE _Moderations SET deleted_at = ? WHERE dispersy_id = ? AND channel_id = ?" - if redo: - deleted_at = None - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, dispersy_id, channel_id)) - - def on_mark_torrent(self, channel_id, dispersy_id, global_time, peer_id, infohash, type, timestamp): - channeltorrent_id = self.addOrGetChannelTorrentID(channel_id, infohash) - - if peer_id: - select = "SELECT global_time FROM TorrentMarkings WHERE channeltorrent_id = ? AND peer_id = ?" - prev_global_time = self._db.fetchone(select, (channeltorrent_id, peer_id)) - else: - select = "SELECT global_time FROM TorrentMarkings WHERE channeltorrent_id = ? AND peer_id IS NULL" - prev_global_time = self._db.fetchone(select, (channeltorrent_id,)) - - if prev_global_time: - if global_time > prev_global_time: - if peer_id: - sql = "DELETE FROM _TorrentMarkings WHERE channeltorrent_id = ? AND peer_id = ?" - self._db.execute_write(sql, (channeltorrent_id, peer_id)) - else: - sql = "DELETE FROM _TorrentMarkings WHERE channeltorrent_id = ? AND peer_id IS NULL" - self._db.execute_write(sql, (channeltorrent_id,)) - else: - return - - sql = """INSERT INTO _TorrentMarkings (dispersy_id, global_time, channeltorrent_id, peer_id, type, time_stamp) - VALUES (?,?,?,?,?,?)""" - self._db.execute_write(sql, (dispersy_id, global_time, channeltorrent_id, peer_id, type, timestamp)) - self.notifier.notify(NTFY_MARKINGS, NTFY_INSERT, channeltorrent_id) - - def on_remove_mark_torrent(self, channel_id, dispersy_id, redo): - sql = "UPDATE _TorrentMarkings SET deleted_at = ? WHERE dispersy_id = ?" - - if redo: - deleted_at = None - else: - deleted_at = long(time()) - self._db.execute_write(sql, (deleted_at, dispersy_id)) - - def on_dynamic_settings(self, channel_id): - self.notifier.notify(NTFY_CHANNELCAST, NTFY_STATE, channel_id) - - def getNrTorrentsDownloaded(self, channel_id): - sql = """select count(*) from MyPreference, ChannelTorrents - WHERE MyPreference.torrent_id = ChannelTorrents.torrent_id and ChannelTorrents.channel_id = ? LIMIT 1""" - return self._db.fetchone(sql, (channel_id,)) - - def getChannelNrTorrents(self, limit=None): - if limit: - sql = """select count(torrent_id), channel_id from Channels, ChannelTorrents - WHERE Channels.id = ChannelTorrents.channel_id AND dispersy_cid <> -1 - GROUP BY channel_id ORDER BY RANDOM() LIMIT ?""" - return self._db.fetchall(sql, (limit,)) - - sql = """SELECT count(torrent_id), channel_id FROM Channels, ChannelTorrents - WHERE Channels.id = ChannelTorrents.channel_id AND dispersy_cid <> -1 GROUP BY channel_id""" - return self._db.fetchall(sql) - - def getChannelNrTorrentsLatestUpdate(self, limit=None): - if limit: - sql = """SELECT count(CollectedTorrent.torrent_id), max(ChannelTorrents.time_stamp), - channel_id from Channels, ChannelTorrents, CollectedTorrent - WHERE ChannelTorrents.torrent_id = CollectedTorrent.torrent_id - AND Channels.id = ChannelTorrents.channel_id AND dispersy_cid == -1 - GROUP BY channel_id ORDER BY RANDOM() LIMIT ?""" - return self._db.fetchall(sql, (limit,)) - - sql = """SELECT count(CollectedTorrent.torrent_id), max(ChannelTorrents.time_stamp), channel_id from Channels, - ChannelTorrents, CollectedTorrent - WHERE ChannelTorrents.torrent_id = CollectedTorrent.torrent_id - AND Channels.id = ChannelTorrents.channel_id AND dispersy_cid == -1 GROUP BY channel_id""" - return self._db.fetchall(sql) - - def getNrChannels(self): - sql = "select count(DISTINCT id) from Channels LIMIT 1" - return self._db.fetchone(sql) - - def getRecentAndRandomTorrents(self, NUM_OWN_RECENT_TORRENTS=15, NUM_OWN_RANDOM_TORRENTS=10, - NUM_OTHERS_RECENT_TORRENTS=15, NUM_OTHERS_RANDOM_TORRENTS=10, - NUM_OTHERS_DOWNLOADED=5): - torrent_dict = {} - - least_recent = -1 - sql = """SELECT dispersy_cid, infohash, time_stamp from ChannelTorrents, Channels, Torrent - WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.channel_id==? and ChannelTorrents.dispersy_id <> -1 order by time_stamp desc limit ?""" - myrecenttorrents = self._db.fetchall(sql, (self._channel_id, NUM_OWN_RECENT_TORRENTS)) - for cid, infohash, timestamp in myrecenttorrents: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - least_recent = timestamp - - if len(myrecenttorrents) == NUM_OWN_RECENT_TORRENTS and least_recent != -1: - sql = """SELECT dispersy_cid, infohash from ChannelTorrents, Channels, Torrent - WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.channel_id==? AND time_stamp -1 order by random() limit ?""" - myrandomtorrents = self._db.fetchall(sql, (self._channel_id, least_recent, NUM_OWN_RANDOM_TORRENTS)) - for cid, infohash, _ in myrecenttorrents: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - - for cid, infohash in myrandomtorrents: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - - nr_records = sum(len(torrents) for torrents in torrent_dict.values()) - additionalSpace = (NUM_OWN_RECENT_TORRENTS + NUM_OWN_RANDOM_TORRENTS) - nr_records - - if additionalSpace > 0: - NUM_OTHERS_RECENT_TORRENTS += additionalSpace / 2 - NUM_OTHERS_RANDOM_TORRENTS += additionalSpace - (additionalSpace / 2) - - # Niels 6-12-2011: we should substract additionalspace from recent and - # random, otherwise the totals will not be correct. - NUM_OWN_RECENT_TORRENTS -= additionalSpace / 2 - NUM_OWN_RANDOM_TORRENTS -= additionalSpace - (additionalSpace / 2) - - least_recent = -1 - sql = """SELECT dispersy_cid, infohash, time_stamp from ChannelTorrents, Channels, Torrent - WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.channel_id in (select channel_id from ChannelVotes - WHERE voter_id ISNULL AND vote=2) and ChannelTorrents.dispersy_id <> -1 ORDER BY time_stamp desc limit ?""" - othersrecenttorrents = self._db.fetchall(sql, (NUM_OTHERS_RECENT_TORRENTS,)) - for cid, infohash, timestamp in othersrecenttorrents: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - least_recent = timestamp - - if othersrecenttorrents and len(othersrecenttorrents) == NUM_OTHERS_RECENT_TORRENTS and least_recent != -1: - sql = """SELECT dispersy_cid, infohash FROM ChannelTorrents, Channels, Torrent - WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.channel_id in (select channel_id from ChannelVotes - WHERE voter_id ISNULL and vote=2) and time_stamp < ? - AND ChannelTorrents.dispersy_id <> -1 order by random() limit ?""" - othersrandomtorrents = self._db.fetchall(sql, (least_recent, NUM_OTHERS_RANDOM_TORRENTS)) - for cid, infohash in othersrandomtorrents: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - - twomonthsago = long(time() - 5259487) - nr_records = sum(len(torrents) for torrents in torrent_dict.values()) - additionalSpace = (NUM_OWN_RECENT_TORRENTS + NUM_OWN_RANDOM_TORRENTS + - NUM_OTHERS_RECENT_TORRENTS + NUM_OTHERS_RANDOM_TORRENTS) - nr_records - NUM_OTHERS_DOWNLOADED += additionalSpace - - sql = """SELECT dispersy_cid, infohash from ChannelTorrents, Channels, Torrent - WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.channel_id in (select distinct channel_id from ChannelTorrents - WHERE torrent_id in (select torrent_id from MyPreference)) - AND ChannelTorrents.dispersy_id <> -1 and Channels.modified > ? order by time_stamp desc limit ?""" - interesting_records = self._db.fetchall(sql, (twomonthsago, NUM_OTHERS_DOWNLOADED)) - for cid, infohash in interesting_records: - torrent_dict.setdefault(str(cid), set()).add(str2bin(infohash)) - - return torrent_dict - - def getRandomTorrents(self, channel_id, limit=15): - sql = """SELECT infohash FROM ChannelTorrents, Torrent WHERE ChannelTorrents.torrent_id = Torrent.torrent_id - AND channel_id = ? ORDER BY RANDOM() LIMIT ?""" - - returnar = [] - for infohash, in self._db.fetchall(sql, (channel_id, limit)): - returnar.append(str2bin(infohash)) - return returnar - - def getTorrentFromChannelId(self, channel_id, infohash, keys): - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id AND channel_id = ? AND infohash = ?""" - result = self._db.fetchone(sql, (channel_id, bin2str(infohash))) - - return self.__fixTorrent(keys, result) - - def getChannelTorrents(self, infohash, keys): - sql = "SELECT " ", ".join(keys) + """ FROM Torrent, ChannelTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id AND infohash = ?""" - results = self._db.fetchall(sql, (bin2str(infohash),)) - - return self.__fixTorrents(keys, results) - - def get_random_channel_torrents(self, keys, limit=10): - """ - Return some random (channel) torrents from the database. - """ - sql = "SELECT %s FROM ChannelTorrents, Torrent " \ - "WHERE ChannelTorrents.torrent_id = Torrent.torrent_id AND Torrent.name IS NOT NULL " \ - "ORDER BY RANDOM() LIMIT ?" % ", ".join(keys) - results = self._db.fetchall(sql, (limit,)) - return self.__fixTorrents(keys, results) - - def getTorrentFromChannelTorrentId(self, channeltorrent_id, keys): - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id AND ChannelTorrents.id = ?""" - result = self._db.fetchone(sql, (channeltorrent_id,)) - if not result: - self._logger.info("COULD NOT FIND CHANNELTORRENT_ID %s", channeltorrent_id) - else: - return self.__fixTorrent(keys, result) - - def getTorrentsFromChannelId(self, channel_id, isDispersy, keys, limit=None, first=0, last=None): - last = last or limit - if isDispersy: - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id""" - else: - sql = "SELECT " + ", ".join(keys) + """ FROM CollectedTorrent as Torrent, ChannelTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id""" - - sql += " AND Torrent.name IS NOT NULL " - - if channel_id: - sql += " AND channel_id = ?" - sql += " ORDER BY time_stamp DESC" - - if limit or last or first: - sql = sql + " LIMIT %i, %i" % (first, last - first if last else first + 1000) - - if channel_id: - results = self._db.fetchall(sql, (channel_id,)) - else: - results = self._db.fetchall(sql) - - if limit is None and channel_id: - # use this possibility to update nrtorrent in channel - - if 'time_stamp' in keys and len(results) > 0: - update = "UPDATE _Channels SET nr_torrents = ?, modified = ? WHERE id = ?" - self._db.execute_write(update, (len(results), results[0][keys.index('time_stamp')], channel_id)) - else: - # use this possibility to update nrtorrent in channel - update = "UPDATE _Channels SET nr_torrents = ? WHERE id = ?" - self._db.execute_write(update, (len(results), channel_id)) - - return self.__fixTorrents(keys, results) - - def getRecentReceivedTorrentsFromChannelId(self, channel_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + " FROM Torrent, ChannelTorrents " + \ - "WHERE Torrent.torrent_id = ChannelTorrents.torrent_id AND channel_id = ? ORDER BY inserted DESC" - if limit: - sql += " LIMIT %d" % limit - results = self._db.fetchall(sql, (channel_id,)) - return self.__fixTorrents(keys, results) - - def getRecentModificationsFromChannelId(self, channel_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM ChannelMetaData - LEFT JOIN MetaDataTorrent ON ChannelMetaData.id = MetaDataTorrent.metadata_id - LEFT JOIN Moderations ON Moderations.cause = ChannelMetaData.dispersy_id - WHERE ChannelMetaData.channel_id = ? - ORDER BY -Moderations.time_stamp ASC, ChannelMetaData.inserted DESC""" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (channel_id,)) - - def getRecentModerationsFromChannel(self, channel_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM Moderations, MetaDataTorrent, ChannelMetaData - WHERE Moderations.cause = ChannelMetaData.dispersy_id - AND ChannelMetaData.id = MetaDataTorrent.metadata_id - AND Moderations.channel_id = ? - ORDER BY Moderations.inserted DESC""" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (channel_id,)) - - def getRecentMarkingsFromChannel(self, channel_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM TorrentMarkings, ChannelTorrents - WHERE TorrentMarkings.channeltorrent_id = ChannelTorrents.id - AND ChannelTorrents.channel_id = ? - ORDER BY TorrentMarkings.time_stamp DESC""" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (channel_id,)) - - def getTorrentsFromPlaylist(self, playlist_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents, PlaylistTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id - AND ChannelTorrents.id = PlaylistTorrents.channeltorrent_id - AND playlist_id = ? ORDER BY time_stamp DESC""" - if limit: - sql += " LIMIT %d" % limit - results = self._db.fetchall(sql, (playlist_id,)) - return self.__fixTorrents(keys, results) - - def getTorrentFromPlaylist(self, playlist_id, infohash, keys): - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents, PlaylistTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id - AND ChannelTorrents.id = PlaylistTorrents.channeltorrent_id - AND playlist_id = ? AND infohash = ?""" - result = self._db.fetchone(sql, (playlist_id, bin2str(infohash))) - - return self.__fixTorrent(keys, result) - - def getRecentTorrentsFromPlaylist(self, playlist_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM Torrent, ChannelTorrents, PlaylistTorrents - WHERE Torrent.torrent_id = ChannelTorrents.torrent_id - AND ChannelTorrents.id = PlaylistTorrents.channeltorrent_id - AND playlist_id = ? ORDER BY inserted DESC""" - if limit: - sql += " LIMIT %d" % limit - results = self._db.fetchall(sql, (playlist_id,)) - return self.__fixTorrents(keys, results) - - def getRecentModificationsFromPlaylist(self, playlist_id, keys, limit=None): - playlistKeys = keys[:] - if 'MetaDataTorrent.channeltorrent_id' in playlistKeys: - playlistKeys[playlistKeys.index('MetaDataTorrent.channeltorrent_id')] = '""' - - sql = "SELECT " + ", ".join(playlistKeys) + """ FROM MetaDataPlaylist, ChannelMetaData - LEFT JOIN Moderations ON Moderations.cause = ChannelMetaData.dispersy_id - WHERE MetaDataPlaylist.metadata_id = ChannelMetaData.id AND playlist_id = ?""" - if limit: - sql += " LIMIT %d" % limit - playlist_modifications = self._db.fetchall(sql, (playlist_id,)) - - sql = "SELECT " + ", ".join(keys) + """ FROM MetaDataTorrent, ChannelMetaData, PlaylistTorrents - LEFT JOIN Moderations ON Moderations.cause = ChannelMetaData.dispersy_id - WHERE MetaDataTorrent.metadata_id = ChannelMetaData.id - AND PlaylistTorrents.channeltorrent_id = MetaDataTorrent.channeltorrent_id AND playlist_id = ?""" - if limit: - sql += " LIMIT %d" % limit - torrent_modifications = self._db.fetchall(sql, (playlist_id,)) - - # merge two lists - orderIndex = keys.index('ChannelMetaData.time_stamp') - revertIndex = keys.index('Moderations.time_stamp') - data = [(row[revertIndex], row[orderIndex], row) for row in playlist_modifications] - data += [(row[revertIndex], row[orderIndex], row) for row in torrent_modifications] - data.sort(reverse=True) - - if limit: - data = data[:limit] - data = [item for _, _, item in data] - return data - - def getRecentModerationsFromPlaylist(self, playlist_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM Moderations, MetaDataTorrent, ChannelMetaData, PlaylistTorrents - WHERE Moderations.cause = ChannelMetaData.dispersy_id - AND ChannelMetaData.id = MetaDataTorrent.metadata_id - AND MetaDataTorrent.channeltorrent_id = PlaylistTorrents.channeltorrent_id - AND PlaylistTorrents.playlist_id = ? ORDER BY Moderations.inserted DESC""" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (playlist_id,)) - - def getRecentMarkingsFromPlaylist(self, playlist_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + """ FROM TorrentMarkings, PlaylistTorrents, ChannelTorrents - WHERE TorrentMarkings.channeltorrent_id = PlaylistTorrents.channeltorrent_id - AND ChannelTorrents.id = PlaylistTorrents.channeltorrent_id - AND PlaylistTorrents.playlist_id = ? - AND ChannelTorrents.dispersy_id <> -1 ORDER BY TorrentMarkings.time_stamp DESC""" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (playlist_id,)) - - def getTorrentsNotInPlaylist(self, channel_id, keys): - sql = "SELECT " + ", ".join(keys) + " FROM Torrent, ChannelTorrents " + \ - "WHERE Torrent.torrent_id = ChannelTorrents.torrent_id " + \ - "AND channel_id = ? " + \ - "And ChannelTorrents.id NOT IN (Select channeltorrent_id From PlaylistTorrents) " + \ - "ORDER BY time_stamp DESC" - results = self._db.fetchall(sql, (channel_id,)) - return self.__fixTorrents(keys, results) - - def getPlaylistForTorrent(self, channeltorrent_id, keys): - sql = "SELECT " + ", ".join(keys) + \ - ", count(DISTINCT channeltorrent_id) FROM Playlists, PlaylistTorrents " + \ - "WHERE Playlists.id = PlaylistTorrents.playlist_id AND channeltorrent_id = ?" - result = self._db.fetchone(sql, (channeltorrent_id,)) - # Niels: 29-02-2012 due to the count this always returns one row, check - # count to return None if playlist was actually not found. - if result[-1]: - return result - - def getPlaylistsForTorrents(self, torrent_ids, keys): - torrent_ids = " ,".join(map(str, torrent_ids)) - - sql = "SELECT channeltorrent_id, " + ", ".join(keys) + \ - ", count(DISTINCT channeltorrent_id) FROM Playlists, PlaylistTorrents " + \ - "WHERE Playlists.id = PlaylistTorrents.playlist_id AND channeltorrent_id IN (" + \ - torrent_ids + ") GROUP BY Playlists.id" - return self._db.fetchall(sql) - - def __fixTorrent(self, keys, torrent): - if len(keys) == 1: - if keys[0] == 'infohash': - return str2bin(torrent) - return torrent - - def fix_value(key, torrent): - if key in keys: - key_index = keys.index(key) - if torrent[key_index]: - torrent[key_index] = str2bin(torrent[key_index]) - - if torrent: - torrent = list(torrent) - fix_value('infohash', torrent) - return torrent - - def __fixTorrents(self, keys, results): - def fix_value(key): - if key in keys: - key_index = keys.index(key) - for i in range(len(results)): - result = list(results[i]) - if result[key_index]: - result[key_index] = str2bin(result[key_index]) - results[i] = result - - fix_value('infohash') - return results - - def getPlaylistsFromChannelId(self, channel_id, keys): - sql = "SELECT " + ", ".join(keys) + \ - ", count(DISTINCT ChannelTorrents.id) FROM Playlists " + \ - "LEFT JOIN PlaylistTorrents ON Playlists.id = PlaylistTorrents.playlist_id " + \ - "LEFT JOIN ChannelTorrents ON PlaylistTorrents.channeltorrent_id = ChannelTorrents.id " + \ - "WHERE Playlists.channel_id = ? GROUP BY Playlists.id ORDER BY Playlists.name DESC" - return self._db.fetchall(sql, (channel_id,)) - - def getPlaylist(self, playlist_id, keys): - sql = "SELECT " + ", ".join(keys) + \ - ", count(DISTINCT ChannelTorrents.id) FROM Playlists " + \ - "LEFT JOIN PlaylistTorrents ON Playlists.id = PlaylistTorrents.playlist_id " + \ - "LEFT JOIN ChannelTorrents ON PlaylistTorrents.channeltorrent_id = ChannelTorrents.id " + \ - "WHERE Playlists.id = ? GROUP BY Playlists.id" - return self._db.fetchone(sql, (playlist_id,)) - - def getCommentsFromChannelId(self, channel_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + " FROM Comments " + \ - "LEFT JOIN Peer ON Comments.peer_id = Peer.peer_id " + \ - "LEFT JOIN CommentPlaylist ON Comments.id = CommentPlaylist.comment_id " + \ - "LEFT JOIN CommentTorrent ON Comments.id = CommentTorrent.comment_id " + \ - "WHERE channel_id = ? ORDER BY time_stamp DESC" - if limit: - sql += " LIMIT %d" % limit - return self._db.fetchall(sql, (channel_id,)) - - def getCommentsFromPlayListId(self, playlist_id, keys, limit=None): - playlistKeys = keys[:] - if 'CommentTorrent.channeltorrent_id' in playlistKeys: - playlistKeys[playlistKeys.index('CommentTorrent.channeltorrent_id')] = '""' - - sql = "SELECT " + ", ".join(playlistKeys) + " FROM Comments " + \ - "LEFT JOIN Peer ON Comments.peer_id = Peer.peer_id " + \ - "LEFT JOIN CommentPlaylist ON Comments.id = CommentPlaylist.comment_id WHERE playlist_id = ?" - if limit: - sql += " LIMIT %d" % limit - - playlist_comments = self._db.fetchall(sql, (playlist_id,)) - - sql = "SELECT " + ", ".join(keys) + " FROM Comments, CommentTorrent, PlaylistTorrents " + \ - "LEFT JOIN Peer ON Comments.peer_id = Peer.peer_id " + \ - "WHERE Comments.id = CommentTorrent.comment_id " + \ - "AND PlaylistTorrents.channeltorrent_id = CommentTorrent.channeltorrent_id AND playlist_id = ?" - if limit: - sql += " LIMIT %d" % limit - - torrent_comments = self._db.fetchall(sql, (playlist_id,)) - - # merge two lists - orderIndex = keys.index('time_stamp') - data = [(row[orderIndex], row) for row in playlist_comments] - data += [(row[orderIndex], row) for row in torrent_comments] - data.sort(reverse=True) - - if limit: - data = data[:limit] - data = [item for _, item in data] - return data - - def getCommentsFromChannelTorrentId(self, channeltorrent_id, keys, limit=None): - sql = "SELECT " + ", ".join(keys) + " FROM Comments, CommentTorrent " + \ - "LEFT JOIN Peer ON Comments.peer_id = Peer.peer_id WHERE Comments.id = CommentTorrent.comment_id " + \ - "AND channeltorrent_id = ? ORDER BY time_stamp DESC" - if limit: - sql += " LIMIT %d" % limit - - return self._db.fetchall(sql, (channeltorrent_id,)) - - def searchChannelsTorrent(self, keywords, limitChannels=None, limitTorrents=None, dispersyOnly=False): - # search channels based on keywords - keywords = split_into_keywords(keywords) - keywords = [keyword for keyword in keywords if len(keyword) > 1] - - if len(keywords) > 0: - sql = "SELECT distinct id, dispersy_cid, name FROM Channels WHERE" - for keyword in keywords: - sql += " name like '%" + keyword + "%' and" - - if dispersyOnly: - sql += " dispersy_cid != '-1'" - else: - sql = sql[:-3] - - if limitChannels: - sql += " LIMIT %d" % limitChannels - - channels = self._db.fetchall(sql) - select_torrents = "SELECT infohash, ChannelTorrents.name, Torrent.name, time_stamp " + \ - "FROM Torrent, ChannelTorrents " + \ - "WHERE Torrent.torrent_id = ChannelTorrents.torrent_id AND channel_id = ? " + \ - "ORDER BY num_seeders DESC LIMIT ?" - - limitTorrents = limitTorrents or 20 - - results = [] - for channel_id, dispersy_cid, name in channels: - dispersy_cid = str(dispersy_cid) - torrents = self._db.fetchall(select_torrents, (channel_id, limitTorrents)) - for infohash, ChTname, CoTname, time_stamp in torrents: - infohash = str2bin(infohash) - results.append((channel_id, dispersy_cid, name, infohash, ChTname or CoTname, time_stamp)) - return results - return [] - - @staticmethod - def calculate_score_channel(keywords, channel_name, channel_description): - """ - Calculate the relevance score of a channel from the database. - The algorithm used is a very stripped-down version of BM25 where only the matching terms are counted. - """ - values = [channel_name, channel_description] - scores = [] - for col_ind in xrange(2): - score = 0 - for keyword in keywords: - term_freq = values[col_ind].lower().count(keyword) - - right_side = ((term_freq * (1.2 + 1)) / (term_freq + 1.2)) - score += right_side - - scores.append(score) - - # The relevance score is 80% dependent on the matching in the channel name - # and 20% on the matching in the channel description. - return 0.8 * scores[0] + 0.2 * scores[1] - - def search_in_local_channels_db(self, query, first=0, last=None, count=False, chan_size_limit=3): - """ - Searches for matching channels against a given query in the database. - """ - search_results = [] - keywords = split_into_keywords(query, to_filter_stopwords=True) - if count: - sql = "SELECT COUNT (*) FROM (SELECT null " - else: - sql = "SELECT id, dispersy_cid, name, description, nr_torrents, nr_favorite, nr_spam, modified " - - sql += "FROM Channels WHERE" - if chan_size_limit: - sql += " nr_torrents >= %i and " % chan_size_limit - sql += " (" - for _ in xrange(len(keywords)): - sql += " name LIKE ? OR description LIKE ? OR " - sql = sql[:-4] + " )" - sql += " LIMIT %i, %i" % (first, last - first if last else first + 1000) - if count: - sql += ")" - - bindings = list(chain.from_iterable(['%%%s%%' % keyword] * 2 for keyword in keywords)) - results = self._db.fetchall(sql, bindings) - - if count: - return results - - my_votes = self.votecast_db.getMyVotes() - - for result in results: - my_vote = my_votes.get(result[0], 0) - - relevance_score = ChannelCastDBHandler.calculate_score_channel(keywords, result[2], result[3]) - extended_result = (result[0], str(result[1]), result[2], result[3], - result[4], result[5], result[6], my_vote, result[7], relevance_score) - search_results.append(extended_result) - - return search_results - - def searchChannels(self, keywords): - sql = "SELECT id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels WHERE" - for keyword in keywords: - sql += " name like '%" + keyword + "%' and" - sql = sql[:-3] - return self._getChannels(sql) - - def getChannel(self, channel_id): - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels WHERE id = ?" - channels = self._getChannels(sql, (channel_id,)) - if len(channels) > 0: - return channels[0] - - def getChannels(self, channel_ids): - channel_ids = "','".join(map(str, channel_ids)) - sql = "Select id, name, description, dispersy_cid, modified, " + \ - "nr_torrents, nr_favorite, nr_spam FROM Channels " + \ - "WHERE id IN ('" + \ - channel_ids + \ - "')" - return self._getChannels(sql) - - def getChannelsByCID(self, channel_cids): - parameters = '?,' * len(channel_cids) - parameters = parameters[:-1] - - channel_cids = map(buffer, channel_cids) - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels WHERE dispersy_cid IN (" + \ - parameters + \ - ")" - return self._getChannels(sql, channel_cids) - - def getAllChannelsCount(self): - sql = "SELECT COUNT (id) FROM CHANNELS" - return self._db.fetchall(sql) - - def getAllChannels(self, first=0, last=None): - """ Returns all the channels """ - sql = ("Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam ") \ - + "FROM Channels " \ - + "WHERE nr_torrents >= 3 " \ - + "LIMIT %i, %i" % (first, last - first if last else first + 1000) - return self._getChannels(sql) - - def getNewChannels(self, updated_since=0): - """ Returns all newest unsubscribed channels, ie the ones with no votes (positive or negative)""" - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels WHERE nr_favorite = 0 AND nr_spam = 0 AND modified > ?" - return self._getChannels(sql, (updated_since,)) - - def getLatestUpdated(self, max_nr=20): - def channel_sort(a, b): - # first compare local vote, spam -> return -1 - if a[7] == -1: - return 1 - if b[7] == -1: - return -1 - - # then compare latest update - if a[8] < b[8]: - return 1 - if a[8] > b[8]: - return -1 - # finally compare nr_torrents - return cmp(a[4], b[4]) - - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels Order By modified DESC Limit ?" - return self._getChannels(sql, (max_nr,), cmpF=channel_sort) - - def getMostPopularChannels(self, max_nr=20): - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels ORDER BY nr_favorite DESC, modified DESC LIMIT ?" - return self._getChannels(sql, (max_nr,), includeSpam=False) - - def getMySubscribedChannels(self, include_dispersy=False, first=0, last=None, count=False): - sql = "SELECT COUNT(*) FROM (SELECT null " if count else "SELECT id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels, ChannelVotes " + \ - "WHERE Channels.id = ChannelVotes.channel_id AND voter_id ISNULL AND vote == 2" - if not include_dispersy: - sql += " AND dispersy_cid == -1 " - - sql = sql + " LIMIT %i, %i" % (first, last - first if last else first + 1000) - if count: - sql += " )" - - return self._getChannels(sql) - - def _getChannels(self, sql, args=None, cmpF=None, includeSpam=True): - """Returns the channels based on the input sql, if the number of positive votes - is less than maxvotes and the number of torrent > 0""" - if self.votecast_db is None: - return [] - - channels = [] - results = self._db.fetchall(sql, args) - - my_votes = self.votecast_db.getMyVotes() - for id, name, description, dispersy_cid, modified, nr_torrents, nr_favorites, nr_spam in results: - my_vote = my_votes.get(id, 0) - if not includeSpam and my_vote < 0: - continue - if len(name.strip()) == 0: - name = "Unnamed channel" - - channels.append((id, str(dispersy_cid), name, description, nr_torrents, - nr_favorites, nr_spam, my_vote, modified, id == self._channel_id)) - - def channel_sort(a, b): - # first compare local vote, spam -> return -1 - if a[7] == -1: - return 1 - if b[7] == -1: - return -1 - - # then compare nr_favorites - if a[5] < b[5]: - return 1 - if a[5] > b[5]: - return -1 - - # then compare latest update - if a[8] < b[8]: - return 1 - if a[8] > b[8]: - return -1 - - # finally compare nr_torrents - return cmp(a[4], b[4]) - - if cmpF is None: - cmpF = channel_sort - channels.sort(cmpF) - return channels - - def getMyChannelId(self): - if self._channel_id: - return self._channel_id - return self._db.fetchone('SELECT id FROM Channels WHERE peer_id ISNULL LIMIT 1') - - def getTorrentMarkings(self, channeltorrent_id): - counts = {} - sql = "SELECT type, peer_id FROM TorrentMarkings WHERE channeltorrent_id = ?" - for type, peer_id in self._db.fetchall(sql, (channeltorrent_id,)): - if type not in counts: - counts[type] = [type, 0, False] - counts[type][1] += 1 - if not peer_id: - counts[type][2] = True - return counts.values() - - def getTorrentModifications(self, channeltorrent_id, keys): - sql = "SELECT " + ", ".join(keys) + """ FROM MetaDataTorrent, ChannelMetaData - LEFT JOIN Moderations ON Moderations.cause = ChannelMetaData.dispersy_id - WHERE metadata_id = ChannelMetaData.id AND channeltorrent_id = ? - ORDER BY -Moderations.time_stamp ASC, prev_global_time DESC""" - return self._db.fetchall(sql, (channeltorrent_id,)) - - def getMostPopularChannelFromTorrent(self, infohash): - """Returns channel id, name, nrfavorites of most popular channel if any""" - sql = """SELECT Channels.id, Channels.dispersy_cid, Channels.name, Channels.description, - Channels.nr_torrents, Channels.nr_favorite, Channels.nr_spam, Channels.modified, - ChannelTorrents.id - FROM Channels, ChannelTorrents, Torrent - WHERE Channels.id = ChannelTorrents.channel_id - AND ChannelTorrents.torrent_id = Torrent.torrent_id AND infohash = ?""" - channels = self._db.fetchall(sql, (bin2str(infohash),)) - - if len(channels) > 0: - channel_ids = set() - for result in channels: - channel_ids.add(result[0]) - - myVotes = self.votecast_db.getMyVotes() - - best_channel = None - for id, dispersy_cid, name, description, nr_torrents, nr_favorites, nr_spam, modified, channeltorrent_id in channels: - channel = id, dispersy_cid, name, description, nr_torrents, nr_favorites, nr_spam, myVotes.get( - id, 0), modified, id == self._channel_id, channeltorrent_id - - # allways prefer mychannel - if channel[-1]: - return channel - - if not best_channel or channel[5] > best_channel[5]: - best_channel = channel - elif channel[5] == best_channel[5] and channel[4] > best_channel[4]: - best_channel = channel - return best_channel - - def get_torrent_ids_from_playlist(self, playlist_id): - """ - Returns the torrent dispersy IDs from a specified playlist. - """ - sql = "SELECT dispersy_id FROM PlaylistTorrents WHERE playlist_id = ?" - return self._db.fetchall(sql, (playlist_id,)) diff --git a/Tribler/Core/CacheDB/__init__.py b/Tribler/Core/CacheDB/__init__.py deleted file mode 100644 index 615239131c9..00000000000 --- a/Tribler/Core/CacheDB/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -The CacheDB package contains the cachedDB for Tribler including a notifier and manages different versions. - -Author(s): Jie Yang -""" diff --git a/Tribler/Core/CacheDB/db_versions.py b/Tribler/Core/CacheDB/db_versions.py deleted file mode 100644 index 82c72d6d591..00000000000 --- a/Tribler/Core/CacheDB/db_versions.py +++ /dev/null @@ -1,35 +0,0 @@ -# Database versions: -# 17 is used by Tribler 5.9.x - 6.0 -# 18 is used by Tribler 6.1.x - 6.2.0 -# 22 is used by Tribler 6.3.x -# 23 is used by Tribler 6.4.0 RC1 -# 24 is used by Tribler 6.4.0 RC2 - 6.4.X -# 25 is used by Tribler 6.5-git -# 26 is used by Tribler 6.5-git (with database upgrade scripts) -# 27 is used by Tribler 6.5-git (TorrentStatus and Category tables are removed) -# 28 is used by Tribler 6.5-git (cleanup Metadata stuff) - -TRIBLER_59_DB_VERSION = 17 -TRIBLER_60_DB_VERSION = 17 - -TRIBLER_61_DB_VERSION = 18 -TRIBLER_62_DB_VERSION = 18 - -TRIBLER_63_DB_VERSION = 22 - -TRIBLER_64RC1_DB_VERSION = 23 - -TRIBLER_64RC2_DB_VERSION = 24 - -TRIBLER_65PRE_DB_VERSION = 25 -TRIBLER_65PRE2_DB_VERSION = 26 -TRIBLER_65PRE3_DB_VERSION = 27 -TRIBLER_65PRE4_DB_VERSION = 28 - -TRIBLER_66_DB_VERSION = 29 - -# the lowest supported database version number -LOWEST_SUPPORTED_DB_VERSION = TRIBLER_59_DB_VERSION - -# the latest database version number -LATEST_DB_VERSION = TRIBLER_66_DB_VERSION diff --git a/Tribler/Core/CacheDB/schema_sdb_v29.sql b/Tribler/Core/CacheDB/schema_sdb_v29.sql deleted file mode 100644 index 1a996987091..00000000000 --- a/Tribler/Core/CacheDB/schema_sdb_v29.sql +++ /dev/null @@ -1,285 +0,0 @@ -BEGIN TRANSACTION create_table; - ----------------------------------------- - -CREATE TABLE MyInfo ( - entry PRIMARY KEY, - value text -); - ----------------------------------------- - -CREATE TABLE MyPreference ( - torrent_id integer PRIMARY KEY NOT NULL, - destination_path text NOT NULL, - creation_time integer NOT NULL -); - ----------------------------------------- - -CREATE TABLE Peer ( - peer_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - permid text NOT NULL, - name text, - thumbnail text -); - -CREATE UNIQUE INDEX permid_idx - ON Peer - (permid); - ----------------------------------------- - -CREATE TABLE Torrent ( - torrent_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - infohash text NOT NULL, - name text, - length integer, - creation_date integer, - num_files integer, - insert_time numeric, - secret integer, - relevance numeric DEFAULT 0, - category text, - status text DEFAULT 'unknown', - num_seeders integer, - num_leechers integer, - comment text, - dispersy_id integer, - is_collected integer DEFAULT 0, - last_tracker_check integer DEFAULT 0, - tracker_check_retries integer DEFAULT 0, - next_tracker_check integer DEFAULT 0 -); - -CREATE UNIQUE INDEX infohash_idx - ON Torrent - (infohash); - ----------------------------------------- - -CREATE TABLE TrackerInfo ( - tracker_id integer PRIMARY KEY AUTOINCREMENT, - tracker text UNIQUE NOT NULL, - last_check numeric DEFAULT 0, - failures integer DEFAULT 0, - is_alive integer DEFAULT 1 -); - -CREATE TABLE TorrentTrackerMapping ( - torrent_id integer NOT NULL, - tracker_id integer NOT NULL, - FOREIGN KEY (torrent_id) REFERENCES Torrent(torrent_id), - FOREIGN KEY (tracker_id) REFERENCES TrackerInfo(tracker_id), - PRIMARY KEY (torrent_id, tracker_id) -); - ----------------------------------------- - -CREATE VIEW CollectedTorrent AS SELECT * FROM Torrent WHERE is_collected == 1; - ----------------------------------------- --- v9: Open2Edit replacing ChannelCast tables - -CREATE TABLE IF NOT EXISTS _Channels ( - id integer PRIMARY KEY ASC, - dispersy_cid text, - peer_id integer, - name text NOT NULL, - description text, - modified integer DEFAULT (strftime('%s','now')), - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - nr_torrents integer DEFAULT 0, - nr_spam integer DEFAULT 0, - nr_favorite integer DEFAULT 0 -); -CREATE VIEW Channels AS SELECT * FROM _Channels WHERE deleted_at IS NULL; - -CREATE TABLE IF NOT EXISTS _ChannelTorrents ( - id integer PRIMARY KEY ASC, - dispersy_id integer, - torrent_id integer NOT NULL, - channel_id integer NOT NULL, - peer_id integer, - name text, - description text, - time_stamp integer, - modified integer DEFAULT (strftime('%s','now')), - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - FOREIGN KEY (channel_id) REFERENCES Channels(id) ON DELETE CASCADE -); -CREATE VIEW ChannelTorrents AS SELECT * FROM _ChannelTorrents WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS TorChannelIndex ON _ChannelTorrents(channel_id); -CREATE INDEX IF NOT EXISTS ChannelTorIndex ON _ChannelTorrents(torrent_id); -CREATE INDEX IF NOT EXISTS ChannelTorChanIndex ON _ChannelTorrents(torrent_id, channel_id); - -CREATE TABLE IF NOT EXISTS _Playlists ( - id integer PRIMARY KEY ASC, - channel_id integer NOT NULL, - dispersy_id integer NOT NULL, - peer_id integer, - playlist_id integer, - name text NOT NULL, - description text, - modified integer DEFAULT (strftime('%s','now')), - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - UNIQUE (dispersy_id), - FOREIGN KEY (channel_id) REFERENCES Channels(id) ON DELETE CASCADE -); -CREATE VIEW Playlists AS SELECT * FROM _Playlists WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS PlayChannelIndex ON _Playlists(channel_id); - -CREATE TABLE IF NOT EXISTS _PlaylistTorrents ( - id integer PRIMARY KEY ASC, - dispersy_id integer NOT NULL, - peer_id integer, - playlist_id integer, - channeltorrent_id integer, - deleted_at integer, - FOREIGN KEY (playlist_id) REFERENCES Playlists(id) ON DELETE CASCADE, - FOREIGN KEY (channeltorrent_id) REFERENCES ChannelTorrents(id) ON DELETE CASCADE -); -CREATE VIEW PlaylistTorrents AS SELECT * FROM _PlaylistTorrents WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS PlayTorrentIndex ON _PlaylistTorrents(playlist_id); - -CREATE TABLE IF NOT EXISTS _Comments ( - id integer PRIMARY KEY ASC, - dispersy_id integer NOT NULL, - peer_id integer, - channel_id integer NOT NULL, - comment text NOT NULL, - reply_to_id integer, - reply_after_id integer, - time_stamp integer, - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - UNIQUE (dispersy_id), - FOREIGN KEY (channel_id) REFERENCES Channels(id) ON DELETE CASCADE -); -CREATE VIEW Comments AS SELECT * FROM _Comments WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS ComChannelIndex ON _Comments(channel_id); - -CREATE TABLE IF NOT EXISTS CommentPlaylist ( - comment_id integer, - playlist_id integer, - PRIMARY KEY (comment_id,playlist_id), - FOREIGN KEY (playlist_id) REFERENCES Playlists(id) ON DELETE CASCADE - FOREIGN KEY (comment_id) REFERENCES Comments(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS CoPlaylistIndex ON CommentPlaylist(playlist_id); - -CREATE TABLE IF NOT EXISTS CommentTorrent ( - comment_id integer, - channeltorrent_id integer, - PRIMARY KEY (comment_id, channeltorrent_id), - FOREIGN KEY (comment_id) REFERENCES Comments(id) ON DELETE CASCADE - FOREIGN KEY (channeltorrent_id) REFERENCES ChannelTorrents(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS CoTorrentIndex ON CommentTorrent(channeltorrent_id); - -CREATE TABLE IF NOT EXISTS _Moderations ( - id integer PRIMARY KEY ASC, - dispersy_id integer NOT NULL, - channel_id integer NOT NULL, - peer_id integer, - severity integer NOT NULL DEFAULT (0), - message text NOT NULL, - cause integer NOT NULL, - by_peer_id integer, - time_stamp integer NOT NULL, - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - UNIQUE (dispersy_id), - FOREIGN KEY (channel_id) REFERENCES Channels(id) ON DELETE CASCADE -); -CREATE VIEW Moderations AS SELECT * FROM _Moderations WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS MoChannelIndex ON _Moderations(channel_id); - -CREATE TABLE IF NOT EXISTS _ChannelMetaData ( - id integer PRIMARY KEY ASC, - dispersy_id integer NOT NULL, - channel_id integer NOT NULL, - peer_id integer, - type text NOT NULL, - value text NOT NULL, - prev_modification integer, - prev_global_time integer, - time_stamp integer NOT NULL, - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - UNIQUE (dispersy_id) -); -CREATE VIEW ChannelMetaData AS SELECT * FROM _ChannelMetaData WHERE deleted_at IS NULL; - -CREATE TABLE IF NOT EXISTS MetaDataTorrent ( - metadata_id integer, - channeltorrent_id integer, - PRIMARY KEY (metadata_id, channeltorrent_id), - FOREIGN KEY (metadata_id) REFERENCES ChannelMetaData(id) ON DELETE CASCADE - FOREIGN KEY (channeltorrent_id) REFERENCES ChannelTorrents(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS MeTorrentIndex ON MetaDataTorrent(channeltorrent_id); - -CREATE TABLE IF NOT EXISTS MetaDataPlaylist ( - metadata_id integer, - playlist_id integer, - PRIMARY KEY (metadata_id,playlist_id), - FOREIGN KEY (playlist_id) REFERENCES Playlists(id) ON DELETE CASCADE - FOREIGN KEY (metadata_id) REFERENCES ChannelMetaData(id) ON DELETE CASCADE -); -CREATE INDEX IF NOT EXISTS MePlaylistIndex ON MetaDataPlaylist(playlist_id); - -CREATE TABLE IF NOT EXISTS _ChannelVotes ( - channel_id integer, - voter_id integer, - dispersy_id integer, - vote integer, - time_stamp integer, - deleted_at integer, - PRIMARY KEY (channel_id, voter_id) -); -CREATE VIEW ChannelVotes AS SELECT * FROM _ChannelVotes WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS ChaVotIndex ON _ChannelVotes(channel_id); -CREATE INDEX IF NOT EXISTS VotChaIndex ON _ChannelVotes(voter_id); - -CREATE TABLE IF NOT EXISTS TorrentFiles ( - torrent_id integer NOT NULL, - path text NOT NULL, - length integer NOT NULL, - PRIMARY KEY (torrent_id, path) -); -CREATE INDEX IF NOT EXISTS TorFileIndex ON TorrentFiles(torrent_id); - -CREATE TABLE IF NOT EXISTS _TorrentMarkings ( - dispersy_id integer NOT NULL, - channeltorrent_id integer NOT NULL, - peer_id integer, - global_time integer, - type text NOT NULL, - time_stamp integer NOT NULL, - deleted_at integer, - UNIQUE (dispersy_id), - PRIMARY KEY (channeltorrent_id, peer_id) -); -CREATE VIEW TorrentMarkings AS SELECT * FROM _TorrentMarkings WHERE deleted_at IS NULL; -CREATE INDEX IF NOT EXISTS TorMarkIndex ON _TorrentMarkings(channeltorrent_id); - -CREATE VIRTUAL TABLE FullTextIndex USING fts4(swarmname, filenames, fileextensions); - -------------------------------------- - -COMMIT TRANSACTION create_table; - ----------------------------------------- - -BEGIN TRANSACTION init_values; - -INSERT INTO MyInfo VALUES ('version', 28); - -INSERT INTO TrackerInfo (tracker) VALUES ('no-DHT'); -INSERT INTO TrackerInfo (tracker) VALUES ('DHT'); - -COMMIT TRANSACTION init_values; diff --git a/Tribler/Core/CacheDB/sqlitecachedb.py b/Tribler/Core/CacheDB/sqlitecachedb.py deleted file mode 100644 index e41256553df..00000000000 --- a/Tribler/Core/CacheDB/sqlitecachedb.py +++ /dev/null @@ -1,527 +0,0 @@ -""" -Sqlitecachedb. - -Author(s): Jie Yang -""" -from __future__ import absolute_import -import logging -import os - -import apsw -from apsw import CantOpenError, SQLError -from base64 import encodestring, decodestring -from threading import currentThread, RLock - -from twisted.python.threadable import isInIOThread - -from Tribler.Core.CacheDB.db_versions import LATEST_DB_VERSION -from Tribler.Core.Utilities.install_dir import get_lib_path -from Tribler.pyipv8.ipv8.taskmanager import TaskManager -from Tribler.pyipv8.ipv8.util import blocking_call_on_reactor_thread - -DB_SCRIPT_NAME = "schema_sdb_v%s.sql" % str(LATEST_DB_VERSION) - -DB_DIR_NAME = u"sqlite" -DB_FILE_NAME = u"tribler.sdb" -DB_FILE_RELATIVE_PATH = os.path.join(DB_DIR_NAME, DB_FILE_NAME) -DB_SCRIPT_ABSOLUTE_PATH = os.path.join(get_lib_path(), 'Core', 'CacheDB', DB_SCRIPT_NAME) - -DEFAULT_BUSY_TIMEOUT = 10000 - -forceDBThread = blocking_call_on_reactor_thread -forceAndReturnDBThread = blocking_call_on_reactor_thread - - -class CorruptedDatabaseError(Exception): - pass - - -def bin2str(bin_data): - return encodestring(bin_data).replace("\n", "") - - -def str2bin(str_data): - return decodestring(str_data) - - -class SQLiteCacheDB(TaskManager): - - def __init__(self, db_path, db_script_path=DB_SCRIPT_ABSOLUTE_PATH, busytimeout=DEFAULT_BUSY_TIMEOUT): - super(SQLiteCacheDB, self).__init__() - - self._logger = logging.getLogger(self.__class__.__name__) - - self._cursor_lock = RLock() - self._cursor_table = {} - - self._connection = None - self.sqlite_db_path = db_path - self.db_script_path = db_script_path - self._busytimeout = busytimeout # busytimeout is in milliseconds - - self._version = None - - self._should_commit = False - self._show_execute = False - self.initialize() - self.initial_begin() - - @property - def version(self): - """The version of this database.""" - return self._version - - @property - def connection(self): - """ - Returns the connection of the database, which may be None if not initialized or closed. - :return: The connection object of the database - """ - return self._connection - - def initialize(self): - """ Initializes the database. If the database doesn't exist, we create a new one. Otherwise, we check the - version and upgrade to the latest version. - """ - - # open a connection to the database - self._open_connection() - - def close(self): - """ - Cancels all pending tasks and closes all cursors. Then, it closes the connection. - """ - self.shutdown_task_manager() - with self._cursor_lock: - self.commit_now(exiting=True) - for cursor in self._cursor_table.itervalues(): - cursor.close() - self._cursor_table = {} - self._connection.close() - self._connection = None - - def _open_connection(self): - """ Opens a connection to the database. If the database doesn't exist, we create a new one and run the - initialization SQL scripts. If the database doesn't exist, we simply connect to it. - And finally, we read the database version. - """ - # check if it is in memory - is_in_memory = self.sqlite_db_path == u":memory:" - is_new_db = is_in_memory - - # check if database file exists - if not is_in_memory: - if not os.path.exists(self.sqlite_db_path): - # create a new one - is_new_db = True - elif not os.path.isfile(self.sqlite_db_path): - msg = u"Not a file: %s" % self.sqlite_db_path - raise OSError(msg) - - # create connection - try: - self._connection = apsw.Connection(self.sqlite_db_path) - self._connection.setbusytimeout(self._busytimeout) - except CantOpenError as e: - msg = u"Failed to open connection to %s: %s" % (self.sqlite_db_path, e) - raise CantOpenError(msg) - - cursor = self.get_cursor() - - # Check integrity of the database only if there is Walk-Ahead Log (WAL) or Shared-Memory (shm) files present - shm_file = "%s-shm" % self.sqlite_db_path - wal_file = "%s-wal" % self.sqlite_db_path - if os.path.exists(shm_file) or os.path.exists(wal_file): - self.do_quick_integrity_check() - - # Enable memory map in sqlite (256MB) - cursor.execute(u"PRAGMA mmap_size=268435456;") - - # apply pragma - page_size, = next(cursor.execute(u"PRAGMA page_size")) - if page_size < 8192: - # journal_mode and page_size only need to be set once. because of the VACUUM this - # is very expensive - self._logger.info(u"begin page_size upgrade...") - cursor.execute(u"PRAGMA journal_mode = DELETE;") - cursor.execute(u"PRAGMA page_size = 8192;") - cursor.execute(u"VACUUM;") - self._logger.info(u"...end page_size upgrade") - - # http://www.sqlite.org/pragma.html - # When synchronous is NORMAL, the SQLite database engine will still - # pause at the most critical moments, but less often than in FULL - # mode. There is a very small (though non-zero) chance that a power - # failure at just the wrong time could corrupt the database in - # NORMAL mode. But in practice, you are more likely to suffer a - # catastrophic disk failure or some other unrecoverable hardware - # fault. - # - cursor.execute(u"PRAGMA synchronous = NORMAL;") - cursor.execute(u"PRAGMA cache_size = 10000;") - - # Niels 19-09-2012: even though my database upgraded to increase the pagesize it did not keep wal mode? - # Enabling WAL on every starup - cursor.execute(u"PRAGMA journal_mode = WAL;") - - # create tables if this is a new database - if is_new_db and self.db_script_path is not None: - self._logger.info(u"Initializing new database...") - # check if the SQL script exists - if not os.path.exists(self.db_script_path): - msg = u"SQL script doesn't exist: %s" % self.db_script_path - raise OSError(msg) - if not os.path.isfile(self.db_script_path): - msg = u"SQL script is not a file: %s" % self.db_script_path - raise OSError(msg) - - try: - f = open(self.db_script_path, "r") - sql_script = f.read() - f.close() - except IOError as e: - msg = u"Failed to load SQL script %s: %s" % (self.db_script_path, e) - raise IOError(msg) - - cursor.execute(sql_script) - - if self.db_script_path is not None: - # read database version - self._logger.info(u"Reading database version...") - try: - version_str, = cursor.execute(u"SELECT value FROM MyInfo WHERE entry == 'version'").next() - self._version = int(version_str) - self._logger.info(u"Current database version is %s", self._version) - except (StopIteration, SQLError) as e: - msg = u"Failed to load database version: %s" % e - raise CorruptedDatabaseError(msg) - else: - self._version = 1 - - def do_quick_integrity_check(self): - check_response, = self.execute(u"PRAGMA quick_check").next() - if check_response != 'ok': - msg = u"Quick integrity check of database failed" - self._logger.error(msg) - raise CorruptedDatabaseError(msg) - - def get_cursor(self): - thread_name = currentThread().getName() - - with self._cursor_lock: - if thread_name not in self._cursor_table: - self._cursor_table[thread_name] = self._connection.cursor() - return self._cursor_table[thread_name] - - def initial_begin(self): - try: - self._logger.info(u"Beginning the first transaction...") - self.execute(u"BEGIN;") - - except: - self._logger.exception(u"Failed to begin the first transaction") - raise - self._should_commit = False - - def write_version(self, version): - assert isinstance(version, int), u"Invalid version type: %s is not int" % type(version) - assert version <= LATEST_DB_VERSION, u"Invalid version value: %s > the latest %s" % (version, LATEST_DB_VERSION) - - sql = u"UPDATE MyInfo SET value = ? WHERE entry == 'version'" - self.execute_write(sql, (version,)) - self.commit_now() - self._version = version - - def commit_now(self, vacuum=False, exiting=False): - if self._should_commit and isInIOThread(): - try: - self._logger.info(u"Start committing...") - self.execute(u"COMMIT;") - except: - self._logger.exception(u"COMMIT FAILED") - if exiting: - # If we are exiting we don't propagate the error. - # The reason for the exit may be the reason this exception occurred. - self._logger.exception(u"Not propagating commit error, as we are exiting") - return - else: - raise - self._should_commit = False - - if vacuum: - self._logger.info(u"Start vacuuming...") - self.execute(u"VACUUM;") - - if not exiting: - try: - self._logger.info(u"Beginning another transaction...") - self.execute(u"BEGIN;") - except: - self._logger.exception(u"Failed to execute BEGIN") - raise - else: - self._logger.info(u"Exiting, not beginning another transaction") - - elif vacuum: - self.execute(u"VACUUM;") - - def clean_db(self, vacuum=False, exiting=False): - self.execute_write(u"DELETE FROM TorrentFiles WHERE torrent_id IN (SELECT torrent_id FROM CollectedTorrent)") - self.execute_write(u"DELETE FROM Torrent WHERE name IS NULL" - u" AND torrent_id NOT IN (SELECT torrent_id FROM _ChannelTorrents)") - - if vacuum: - self.commit_now(vacuum, exiting=exiting) - - def set_show_sql(self, switch): - self._show_execute = switch - - # --------- generic functions ------------- - - def execute(self, sql, args=None): - cur = self.get_cursor() - - if self._show_execute: - thread_name = currentThread().getName() - self._logger.info(u"===%s===\n%s\n-----\n%s\n======\n", thread_name, sql, args) - - try: - if args is None: - return cur.execute(sql) - else: - return cur.execute(sql, args) - - except Exception as msg: - if str(msg).startswith(u"BusyError"): - self._logger.error(u"cachedb: busylock error") - - else: - thread_name = currentThread().getName() - self._logger.exception(u"cachedb: ===%s===\nSQL Type: %s\n-----\n%s\n-----\n%s\n======\n", - thread_name, type(sql), sql, args) - - raise msg - - def executemany(self, sql, args=None): - self._should_commit = True - - cur = self.get_cursor() - if self._show_execute: - thread_name = currentThread().getName() - self._logger.info(u"===%s===\n%s\n-----\n%s\n======\n", thread_name, sql, args) - - try: - if args is None: - result = cur.executemany(sql) - else: - result = cur.executemany(sql, args) - - return result - - except Exception as msg: - thread_name = currentThread().getName() - self._logger.exception(u"===%s===\nSQL Type: %s\n-----\n%s\n-----\n%s\n======\n", - thread_name, type(sql), sql, args) - raise msg - - def execute_read(self, sql, args=None): - return self.execute(sql, args) - - def execute_write(self, sql, args=None): - self._should_commit = True - - self.execute(sql, args) - - def insert_or_ignore(self, table_name, **argv): - if len(argv) == 1: - sql = u'INSERT OR IGNORE INTO %s (%s) VALUES (?);' % (table_name, argv.keys()[0]) - else: - questions = '?,' * len(argv) - sql = u'INSERT OR IGNORE INTO %s %s VALUES (%s);' % (table_name, tuple(argv.keys()), questions[:-1]) - self.execute_write(sql, argv.values()) - - def insert(self, table_name, **argv): - if len(argv) == 1: - sql = u'INSERT INTO %s (%s) VALUES (?);' % (table_name, argv.keys()[0]) - else: - questions = '?,' * len(argv) - sql = u'INSERT INTO %s %s VALUES (%s);' % (table_name, tuple(argv.keys()), questions[:-1]) - self.execute_write(sql, argv.values()) - - # TODO: may remove this, only used by test_sqlitecachedb.py - def insertMany(self, table_name, values, keys=None): - """ values must be a list of tuples """ - - questions = u'?,' * len(values[0]) - if keys is None: - sql = u'INSERT INTO %s VALUES (%s);' % (table_name, questions[:-1]) - else: - sql = u'INSERT INTO %s %s VALUES (%s);' % (table_name, tuple(keys), questions[:-1]) - self.executemany(sql, values) - - def update(self, table_name, where=None, **argv): - assert len(argv) > 0, 'NO VALUES TO UPDATE SPECIFIED' - if len(argv) > 0: - sql = u'UPDATE %s SET ' % table_name - arg = [] - for k, v in argv.iteritems(): - if isinstance(v, tuple): - sql += u'%s %s ?,' % (k, v[0]) - arg.append(v[1]) - else: - sql += u'%s=?,' % k - arg.append(v) - sql = sql[:-1] - if where is not None: - sql += u' WHERE %s' % where - self.execute_write(sql, arg) - - def delete(self, table_name, **argv): - sql = u'DELETE FROM %s WHERE ' % table_name - arg = [] - for k, v in argv.iteritems(): - if isinstance(v, tuple): - sql += u'%s %s ? AND ' % (k, v[0]) - arg.append(v[1]) - else: - sql += u'%s=? AND ' % k - arg.append(v) - sql = sql[:-5] - self.execute_write(sql, arg) - - # -------- Read Operations -------- - def size(self, table_name): - num_rec_sql = u"SELECT count(*) FROM %s LIMIT 1" % table_name - result = self.fetchone(num_rec_sql) - return result - - def fetchone(self, sql, args=None): - find = self.execute_read(sql, args) - if not find: - return - else: - find = list(find) - if len(find) > 0: - if len(find) > 1: - self._logger.debug( - u"FetchONE resulted in many more rows than one, consider putting a LIMIT 1 in the sql statement %s, %s", sql, len(find)) - find = find[0] - else: - return - if len(find) > 1: - return find - else: - return find[0] - - def fetchall(self, sql, args=None): - res = self.execute_read(sql, args) - if res is not None: - find = list(res) - return find - else: - return [] # should it return None? - - def getOne(self, table_name, value_name, where=None, conj=u"AND", **kw): - """ value_name could be a string, a tuple of strings, or '*' - """ - if isinstance(value_name, tuple): - value_names = u",".join(value_name) - elif isinstance(value_name, list): - value_names = u",".join(value_name) - else: - value_names = value_name - - if isinstance(table_name, tuple): - table_names = u",".join(table_name) - elif isinstance(table_name, list): - table_names = u",".join(table_name) - else: - table_names = table_name - - sql = u'SELECT %s FROM %s' % (value_names, table_names) - - if where or kw: - sql += u' WHERE ' - if where: - sql += where - if kw: - sql += u' %s ' % conj - if kw: - arg = [] - for k, v in kw.iteritems(): - if isinstance(v, tuple): - operator = v[0] - arg.append(v[1]) - else: - operator = "=" - arg.append(v) - sql += u' %s %s ? ' % (k, operator) - sql += conj - sql = sql[:-len(conj)] - else: - arg = None - - # print >> sys.stderr, 'SQL: %s %s' % (sql, arg) - return self.fetchone(sql, arg) - - def getAll(self, table_name, value_name, where=None, group_by=None, having=None, order_by=None, limit=None, - offset=None, conj=u"AND", **kw): - """ value_name could be a string, or a tuple of strings - order by is represented as order_by - group by is represented as group_by - """ - if isinstance(value_name, tuple): - value_names = u",".join(value_name) - elif isinstance(value_name, list): - value_names = u",".join(value_name) - else: - value_names = value_name - - if isinstance(table_name, tuple): - table_names = u",".join(table_name) - elif isinstance(table_name, list): - table_names = u",".join(table_name) - else: - table_names = table_name - - sql = u'SELECT %s FROM %s' % (value_names, table_names) - - if where or kw: - sql += u' WHERE ' - if where: - sql += where - if kw: - sql += u' %s ' % conj - if kw: - arg = [] - for k, v in kw.iteritems(): - if isinstance(v, tuple): - operator = v[0] - arg.append(v[1]) - else: - operator = u"=" - arg.append(v) - - sql += u' %s %s ?' % (k, operator) - sql += conj - sql = sql[:-len(conj)] - else: - arg = None - - if group_by is not None: - sql += u' GROUP BY ' + group_by - if having is not None: - sql += u' HAVING ' + having - if order_by is not None: - # you should add desc after order_by to reversely sort, i.e, 'last_seen desc' as order_by - sql += u' ORDER BY ' + order_by - if limit is not None: - sql += u' LIMIT %d' % limit - if offset is not None: - sql += u' OFFSET %d' % offset - - try: - return self.fetchall(sql, arg) or [] - except Exception as msg: - self._logger.exception(u"Wrong getAll sql statement: %s", sql) - raise Exception(msg) diff --git a/Tribler/Core/Config/config.spec b/Tribler/Core/Config/config.spec index e9ce65574ad..967f47b82ac 100644 --- a/Tribler/Core/Config/config.spec +++ b/Tribler/Core/Config/config.spec @@ -2,22 +2,9 @@ family_filter = boolean(default=True) state_dir = string(default='') ec_keypair_filename = string(default='') -megacache = boolean(default=True) log_dir = string(default=None) testnet = boolean(default=False) -[allchannel_community] -enabled = boolean(default=True) - -[channel_community] -enabled = boolean(default=True) - -[preview_channel_community] -enabled = boolean(default=True) - -[search_community] -enabled = boolean(default=True) - [tunnel_community] enabled = boolean(default=True) socks5_listen_ports = string_list(default=list('-1', '-1', '-1', '-1', '-1')) @@ -49,10 +36,6 @@ enabled = boolean(default=True) channel_edit = boolean(default=False) channels_dir = string(default='channels') -[metadata] -enabled = boolean(default=True) -store_dir = string(default=collected_metadata) - [mainline_dht] enabled = boolean(default=True) port = integer(min=-1, max=65536, default=-1) @@ -60,15 +43,6 @@ port = integer(min=-1, max=65536, default=-1) [torrent_checking] enabled = boolean(default=True) -[torrent_store] -enabled = boolean(default=True) -store_dir = string(default=collected_torrents) - -[torrent_collecting] -enabled = boolean(default=True) -max_torrents = integer(default=50000) -directory = string(default='') - [libtorrent] enabled = boolean(default=True) port = integer(min=-1, max=65536, default=-1) @@ -97,12 +71,9 @@ seeding_ratio = float(default=2.0) seeding_time = float(default=60) channel_download = boolean(default=False) -[dispersy] -enabled = boolean(default=True) -port = integer(min=-1, max=65536, default=-1) - [ipv8] enabled = boolean(default=True) +port = integer(min=-1, max=65536, default=-1) address = string(default='0.0.0.0') bootstrap_override = string(default='') statistics = boolean(default=False) diff --git a/Tribler/Core/Config/tribler_config.py b/Tribler/Core/Config/tribler_config.py index 94b14591e4c..1b29d4be672 100644 --- a/Tribler/Core/Config/tribler_config.py +++ b/Tribler/Core/Config/tribler_config.py @@ -196,12 +196,6 @@ def set_trustchain_live_edges_enabled(self, value): def get_trustchain_live_edges_enabled(self): return self.config['trustchain']['live_edges_enabled'] - def set_megacache_enabled(self, value): - self.config['general']['megacache'] = value - - def get_megacache_enabled(self): - return self.config['general']['megacache'] - def set_log_dir(self, value): self.config['general']['log_dir'] = value @@ -252,20 +246,6 @@ def set_http_api_retry_port(self, retry_port): def get_http_api_retry_port(self): return self.config['http_api']['retry_port'] - # Dispersy - - def set_dispersy_enabled(self, value): - self.config['dispersy']['enabled'] = value - - def get_dispersy_enabled(self): - return self.config['dispersy']['enabled'] - - def set_dispersy_port(self, value): - self.config['dispersy']['port'] = value - - def get_dispersy_port(self): - return self._obtain_port('dispersy', 'port') - # IPv8 def set_ipv8_enabled(self, value): @@ -274,6 +254,12 @@ def set_ipv8_enabled(self, value): def get_ipv8_enabled(self): return self.config['ipv8']['enabled'] + def set_ipv8_port(self, value): + self.config['ipv8']['port'] = value + + def get_ipv8_port(self): + return self._obtain_port('ipv8', 'port') + def set_ipv8_bootstrap_override(self, value): self.config['ipv8']['bootstrap_override'] = value @@ -578,86 +564,6 @@ def get_popularity_community_enabled(self): def set_popularity_community_enabled(self, value): self.config['popularity_community']['enabled'] = value - # Torrent store - - def get_torrent_store_enabled(self): - return self.config['torrent_store']['enabled'] - - def set_torrent_store_enabled(self, value): - self.config['torrent_store']['enabled'] = value - - def get_torrent_store_dir(self): - return os.path.join(self.get_state_dir(), self.config['torrent_store']['store_dir']) - - def set_torrent_store_dir(self, value): - self.config['torrent_store']['store_dir'] = value - - # Metadata - - def get_metadata_enabled(self): - return self.config['metadata']['enabled'] - - def set_metadata_enabled(self, mode): - self.config['metadata']['enabled'] = mode - - def get_metadata_store_dir(self): - return os.path.join(self.get_state_dir(), self.config['metadata']['store_dir']) - - def set_metadata_store_dir(self, value): - self.config['metadata']['store_dir'] = value - - # Torrent collecting - - def set_torrent_collecting_enabled(self, value): - self.config['torrent_collecting']['enabled'] = value - - def get_torrent_collecting_enabled(self): - return self.config['torrent_collecting']['enabled'] - - def set_torrent_collecting_max_torrents(self, value): - self.config['torrent_collecting']['max_torrents'] = value - - def get_torrent_collecting_max_torrents(self): - return self.config['torrent_collecting']['max_torrents'] - - def set_torrent_collecting_dir(self, value): - self.config['torrent_collecting']['directory'] = value - - def get_torrent_collecting_dir(self): - return self.config['torrent_collecting']['directory'] - - # Search Community - - def set_torrent_search_enabled(self, mode): - self.config['search_community']['enabled'] = mode - - def get_torrent_search_enabled(self): - return self.config['search_community']['enabled'] - - # AllChannel Community - - def set_channel_search_enabled(self, mode): - self.config['allchannel_community']['enabled'] = mode - - def get_channel_search_enabled(self): - return self.config['allchannel_community']['enabled'] - - # Channel Community - - def set_channel_community_enabled(self, value): - self.config['channel_community']['enabled'] = value - - def get_channel_community_enabled(self): - return self.config['channel_community']['enabled'] - - # PreviewChannel Community - - def set_preview_channel_community_enabled(self, value): - self.config['preview_channel_community']['enabled'] = value - - def get_preview_channel_community_enabled(self): - return self.config['preview_channel_community']['enabled'] - # Watch folder def set_watch_folder_enabled(self, value): diff --git a/Tribler/Core/CreditMining/CreditMiningManager.py b/Tribler/Core/CreditMining/CreditMiningManager.py index 3c42ddc4f11..d53a234b99c 100644 --- a/Tribler/Core/CreditMining/CreditMiningManager.py +++ b/Tribler/Core/CreditMining/CreditMiningManager.py @@ -3,14 +3,12 @@ import logging import os +import psutil import time from binascii import hexlify, unhexlify from glob import glob -import psutil - from six import string_types - from twisted.internet.defer import Deferred, DeferredList, succeed from twisted.internet.task import LoopingCall @@ -19,7 +17,8 @@ from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.TorrentDef import TorrentDefNoMetainfo from Tribler.Core.simpledefs import DLSTATUS_DOWNLOADING, DLSTATUS_SEEDING, DLSTATUS_STOPPED, \ - DLSTATUS_STOPPED_ON_ERROR, DOWNLOAD, NTFY_CREDIT_MINING, NTFY_ERROR, UPLOAD + DLSTATUS_STOPPED_ON_ERROR, NTFY_CREDIT_MINING, NTFY_ERROR, UPLOAD +from Tribler.Core.simpledefs import DOWNLOAD from Tribler.pyipv8.ipv8.taskmanager import TaskManager @@ -27,6 +26,7 @@ class CreditMiningTorrent(object): """ Wrapper class for Credit Mining download """ + def __init__(self, infohash, name, download=None, state=None): self.infohash = infohash self.name = name @@ -49,6 +49,7 @@ class CreditMiningSettings(object): """ This class contains settings used by the credit mining manager """ + def __init__(self, config=None): self.max_torrents_active = 8 self.max_torrents_listed = 100 @@ -158,8 +159,8 @@ def add_source(self, source_str): if source_str not in self.sources: num_torrents = len(self.torrents) - if isinstance(source_str, string_types) and len(source_str) == 40: - source = ChannelSource(self.session, source_str, self.on_torrent_insert) + if isinstance(source_str, string_types): + source = ChannelSource(self.session, unhexlify(source_str), self.on_torrent_insert) else: self._logger.error('Cannot add unknown source %s', source_str) return @@ -222,7 +223,7 @@ def on_torrent_insert(self, source_str, infohash, name): # If a download already exists or already has a checkpoint, skip this torrent if self.session.get_download(unhexlify(infohash)) or \ - os.path.exists(os.path.join(self.session.get_downloads_pstate_dir(), infohash + '.state')): + os.path.exists(os.path.join(self.session.get_downloads_pstate_dir(), infohash + '.state')): self._logger.debug('Skipping torrent %s (download already running or scheduled to run)', infohash) return @@ -337,7 +338,7 @@ def monitor_downloads(self, dslist): self._logger.info('Downloading: %d, Uploading: %d, Stopped: %d', num_seeding, num_downloading, stopped) self._logger.info('%d active download(s), %.3f MB uploaded, %.3f MB downloaded', - num_seeding + num_downloading, bytes_uploaded/MB, bytes_downloaded/MB) + num_seeding + num_downloading, bytes_uploaded / MB, bytes_downloaded / MB) if not self.session_ready.called and len(dslist) == self.num_checkpoints: self.session_ready.callback(None) diff --git a/Tribler/Core/CreditMining/CreditMiningSource.py b/Tribler/Core/CreditMining/CreditMiningSource.py index 8132e58d4b3..39817a82fa6 100644 --- a/Tribler/Core/CreditMining/CreditMiningSource.py +++ b/Tribler/Core/CreditMining/CreditMiningSource.py @@ -2,16 +2,15 @@ import logging -from binascii import hexlify, unhexlify +from binascii import hexlify -from Tribler.dispersy.exception import CommunityNotFoundException -from Tribler.Core.simpledefs import NTFY_DISCOVERED, NTFY_TORRENT, NTFY_CHANNELCAST from Tribler.pyipv8.ipv8.taskmanager import TaskManager class BaseSource(TaskManager): """ - Base class for credit mining source. For now, it can only be a Dispersy channel + Base class for a credit mining source. + The source specifies where to get torrents from. """ def __init__(self, session, source, torrent_insert_cb): @@ -47,57 +46,20 @@ def __str__(self): class ChannelSource(BaseSource): """ - Credit mining source from a channel. + Credit mining source from a (giga)channel. """ - def __init__(self, session, dispersy_cid, torrent_insert_cb): - super(ChannelSource, self).__init__(session, dispersy_cid, torrent_insert_cb) - self.community = None - self.channelcast_db = self.session.open_dbhandler(NTFY_CHANNELCAST) - def start(self): super(ChannelSource, self).start() - # Join the community if needed - dispersy = self.session.get_dispersy_instance() - try: - self.community = dispersy.get_community(unhexlify(self.source), True) - except CommunityNotFoundException: - from Tribler.community.allchannel.community import AllChannelCommunity - from Tribler.community.channel.community import ChannelCommunity - - allchannelcommunity = None - for community in dispersy.get_communities(): - if isinstance(community, AllChannelCommunity): - allchannelcommunity = community - - if allchannelcommunity: - self.community = ChannelCommunity.init_community(dispersy, - dispersy.get_member(mid=unhexlify(self.source)), - allchannelcommunity.my_member, self.session) - self._logger.info('Joined channel community %s', self.source) - else: - self._logger.error('Could not find AllChannelCommunity') - return + channel = self.session.lm.mds.ChannelMetadata.get_channel_with_id(self.source) + if not channel: + self._logger.error("Could not find channel!") + return # Add torrents from database - channel_id = self.community.get_channel_id() - torrents = self.channelcast_db.getTorrentsFromChannelId(channel_id, True, - ['infohash', 'ChannelTorrents.name']) - - for infohash_bin, name in torrents: - self.torrent_insert_callback(self.source, hexlify(infohash_bin), name) - - self.session.add_observer(self.on_torrent_discovered, NTFY_TORRENT, [NTFY_DISCOVERED]) - - def stop(self): - super(ChannelSource, self).stop() - self.session.remove_observer(self.on_torrent_discovered) - - def on_torrent_discovered(self, subject, changetype, objectID, object_dict): - # Add newly discovered torrents - if self.source == object_dict['dispersy_cid']: - self.torrent_insert_callback(self.source, object_dict['infohash'], object_dict['name']) + for torrent in channel.contents_list: + self.torrent_insert_callback(self.source, hexlify(torrent.infohash), torrent.title) def __str__(self): return 'channel:' + self.source diff --git a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py index 2afe384ddac..3557e1a3252 100644 --- a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py +++ b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py @@ -11,18 +11,16 @@ import sys import time from binascii import hexlify +from threading import RLock import libtorrent as lt - import six from six.moves import xrange - from twisted.internet import reactor from twisted.internet.defer import CancelledError, Deferred, succeed from twisted.internet.task import LoopingCall from twisted.python.failure import Failure -from Tribler.Core import NoDispersyRLock from Tribler.Core.DownloadConfig import DownloadConfigInterface, DownloadStartupConfig, get_default_dest_dir from Tribler.Core.DownloadState import DownloadState from Tribler.Core.Libtorrent import checkHandleAndSynchronize @@ -51,7 +49,7 @@ def __init__(self, f, d): self._download = d pieces = self._download.tdef.get_pieces() - self.pieces = [pieces[x:x + 20]for x in xrange(0, len(pieces), 20)] + self.pieces = [pieces[x:x + 20] for x in xrange(0, len(pieces), 20)] self.piecesize = self._download.tdef.get_piece_length() self.startpiece = get_info_from_handle(self._download.handle).map_file( @@ -64,7 +62,9 @@ def read(self, *args): self._logger.debug('VODFile: get bytes %s - %s', oldpos, oldpos + args[0]) - while not self._file.closed and self._download.get_byte_progress([(self._download.get_vod_fileindex(), oldpos, oldpos + args[0])]) < 1 and self._download.vod_seekpos is not None: + while not self._file.closed and self._download.get_byte_progress([(self._download.get_vod_fileindex(), oldpos, + oldpos + args[ + 0])]) < 1 and self._download.vod_seekpos is not None: time.sleep(1) if self._file.closed: @@ -94,7 +94,7 @@ def seek(self, *args): self._logger.debug('VODFile: seek, get pieces %s', self._download.handle.piece_priorities()) self._logger.debug('VODFile: seek, got pieces %s', [ - int(piece) for piece in self._download.handle.status().pieces]) + int(piece) for piece in self._download.handle.status().pieces]) def close(self, *args): self._file.close(*args) @@ -105,7 +105,6 @@ def closed(self): class LibtorrentDownloadImpl(DownloadConfigInterface, TaskManager): - """ Download subclass that represents a libtorrent download.""" def __init__(self, session, tdef): @@ -113,7 +112,7 @@ def __init__(self, session, tdef): self._logger = logging.getLogger(self.__class__.__name__) - self.dllock = NoDispersyRLock() + self.dllock = RLock() self.session = session self.tdef = tdef self.handle = None @@ -396,7 +395,7 @@ def get_pieces_base64(self): encoded_str = "" for i in range(0, len(bitstr), 8): - encoded_str += chr(int(bitstr[i:i+8].ljust(8, '0'), 2)) + encoded_str += chr(int(bitstr[i:i + 8].ljust(8, '0'), 2)) return base64.b64encode(encoded_str) @checkHandleAndSynchronize(0.0) @@ -438,7 +437,8 @@ def set_piece_priority(self, pieces_need, priority): do_prio = True else: self._logger.info( - "LibtorrentDownloadImpl: could not set priority for non-existing piece %d / %d", piece, len(piecepriorities)) + "LibtorrentDownloadImpl: could not set priority for non-existing piece %d / %d", piece, + len(piecepriorities)) if do_prio: self.handle.prioritize_pieces(piecepriorities) else: @@ -574,11 +574,6 @@ def on_metadata_received_alert(self, alert): self.set_filepieceranges() self.set_selected_files() - if self.session.lm.rtorrent_handler: - self.session.lm.rtorrent_handler.save_torrent(self.tdef) - elif self.session.lm.torrent_db: - self.session.lm.torrent_db.addExternalTorrent(self.tdef, extra_info={'status': 'good'}) - self.checkpoint() def on_file_renamed_alert(self, alert): @@ -645,6 +640,7 @@ def reset_priorities(): return if self.get_state().get_progress() == 1.0: self.set_byte_priority([(self.get_vod_fileindex(), 0, -1)], 1) + self.register_anonymous_task("reset_priorities", reactor.callLater(5, reset_priorities)) if self.endbuffsize: @@ -670,7 +666,8 @@ def set_corrected_infoname(self): self.correctedinfoname = fix_filebasename(self.tdef.get_name_as_unicode()) # Allow correctedinfoname to be overwritten for multifile torrents only - if self.get_corrected_filename() and self.get_corrected_filename() != '' and 'files' in self.tdef.get_metainfo()['info']: + if self.get_corrected_filename() and self.get_corrected_filename() != '' and 'files' in \ + self.tdef.get_metainfo()['info']: self.correctedinfoname = self.get_corrected_filename() @property @@ -814,8 +811,9 @@ def calc_prebuf_frac(self, consecutive=False): [(self.get_vod_fileindex(), self.vod_seekpos, self.vod_seekpos + self.prebuffsize), (self.get_vod_fileindex(), -self.endbuffsize - 1, -1)], consecutive=consecutive) else: - return self.get_byte_progress([(self.get_vod_fileindex(), self.vod_seekpos, self.vod_seekpos + self.prebuffsize)], - consecutive=consecutive) + return self.get_byte_progress( + [(self.get_vod_fileindex(), self.vod_seekpos, self.vod_seekpos + self.prebuffsize)], + consecutive=consecutive) else: return 0.0 @@ -1042,7 +1040,7 @@ def checkpoint(self): filename = os.path.join(self.session.get_downloads_pstate_dir(), basename) if not os.path.isfile(filename): resume_data = self.pstate_for_restart.get('state', 'engineresumedata') \ - if self.pstate_for_restart else None + if self.pstate_for_restart else None # 2. If there is no saved data for this infohash, checkpoint it without data so we do not # lose it when we crash or restart before the download becomes known. @@ -1051,7 +1049,7 @@ def checkpoint(self): 'file-version': 1, 'info-hash': self.tdef.get_infohash() } - alert = type('anonymous_alert', (object, ), dict(resume_data=resume_data)) + alert = type('anonymous_alert', (object,), dict(resume_data=resume_data)) self.on_save_resume_data_alert(alert) return succeed(None) @@ -1068,7 +1066,8 @@ def get_persistent_download_config(self): pstate.set('state', 'version', PERSISTENTSTATE_CURRENTVERSION) if isinstance(self.tdef, TorrentDefNoMetainfo): pstate.set('state', 'metainfo', { - 'infohash': self.tdef.get_infohash(), 'name': self.tdef.get_name_as_unicode(), 'url': self.tdef.get_url()}) + 'infohash': self.tdef.get_infohash(), 'name': self.tdef.get_name_as_unicode(), + 'url': self.tdef.get_url()}) else: pstate.set('state', 'metainfo', self.tdef.get_metainfo()) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index 1aca8125e64..fc4dee821a7 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -44,6 +44,20 @@ class Metadata(db.Entity): _clock = None def __init__(self, *args, **kwargs): + """ + Initialize a metadata object. + + Note: this is a way to manually define Pony entity default attributes in case we really + have to generate the signature before creating the object + from pony.orm.core import DEFAULT + def generate_dict_from_pony_args(cls, **kwargs): + d = {} + for attr in cls._attrs_: + val = kwargs.get(attr.name, DEFAULT) + d[attr.name] = attr.validate(val, entity=cls) + return d + """ + # Special "sign_with" argument given, sign with it private_key_override = None if "sign_with" in kwargs: @@ -74,18 +88,6 @@ def __init__(self, *args, **kwargs): (hexlify(self.signature) if self.signature else "empty signature ") + " / " + (hexlify(self.public_key) if self.public_key else " empty PK")) - """ - # This is a way to manually define Pony entity default attributes in case we really - have to generate the signature before creating the object - from pony.orm.core import DEFAULT - def generate_dict_from_pony_args(cls, **kwargs): - d = {} - for attr in cls._attrs_: - val = kwargs.get(attr.name, DEFAULT) - d[attr.name] = attr.validate(val, entity=cls) - return d - """ - def _serialized(self, key=None): """ Serializes the object and returns the result with added signature (tuple output) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py index a4c35c1f76c..be96826ad37 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py @@ -1,8 +1,5 @@ -from datetime import datetime - from pony import orm -from Tribler.Core.Modules.MetadataStore.serialization import EPOCH from Tribler.pyipv8.ipv8.database import database_blob @@ -11,7 +8,7 @@ class TorrentState(db.Entity): infohash = orm.PrimaryKey(database_blob) seeders = orm.Optional(int, default=0) leechers = orm.Optional(int, default=0) - last_check = orm.Optional(datetime, default=EPOCH) + last_check = orm.Optional(int, size=64, default=0) metadata = orm.Set('TorrentMetadata') trackers = orm.Set('TrackerState', reverse='torrents') diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py index 002fae169dc..1d838c561af 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py @@ -1,15 +1,12 @@ -from datetime import datetime - from pony import orm -from Tribler.Core.Modules.MetadataStore.serialization import EPOCH from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url, MalformedTrackerURLException def define_binding(db): class TrackerState(db.Entity): url = orm.PrimaryKey(str) - last_check = orm.Optional(datetime, default=EPOCH) + last_check = orm.Optional(int, size=64, default=0) alive = orm.Optional(bool, default=True) torrents = orm.Set('TorrentState', reverse='trackers') failures = orm.Optional(int, size=32, default=0) diff --git a/Tribler/Core/Modules/channel/__init__.py b/Tribler/Core/Modules/channel/__init__.py deleted file mode 100644 index 51f29aa53c7..00000000000 --- a/Tribler/Core/Modules/channel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Channels are lists of torrents created by users. -""" diff --git a/Tribler/Core/Modules/channel/cache.py b/Tribler/Core/Modules/channel/cache.py deleted file mode 100644 index 7ac9b46a550..00000000000 --- a/Tribler/Core/Modules/channel/cache.py +++ /dev/null @@ -1,46 +0,0 @@ -import codecs -import logging -import os - -import Tribler.Core.Utilities.json_util as json - - -class SimpleCache(object): - """ - This is a cache for recording the keys that we have seen before. - """ - def __init__(self, file_path): - self._logger = logging.getLogger(self.__class__.__name__) - self._file_path = file_path - - self._cache_list = list() - self._initial_cache_size = 0 - - def add(self, key): - if not self.has(key): - self._cache_list.append(key) - - def has(self, key): - return key in self._cache_list - - def load(self): - if os.path.exists(self._file_path): - try: - with codecs.open(self._file_path, 'rb', encoding='utf-8') as f: - self._cache_list = json.load(f) - except Exception as e: - self._logger.error(u"Failed to load cache file %s: %s", self._file_path, repr(e)) - else: - self._cache_list = list() - self._initial_cache_size = len(self._cache_list) - - def save(self): - if self._initial_cache_size == len(self._cache_list): - return - try: - with codecs.open(self._file_path, 'wb', encoding='utf-8') as f: - json.dump(self._cache_list, f) - self._initial_cache_size = len(self._cache_list) - except Exception as e: - self._logger.error(u"Failed to save cache file %s: %s", self._file_path, repr(e)) - return diff --git a/Tribler/Core/Modules/channel/channel.py b/Tribler/Core/Modules/channel/channel.py deleted file mode 100644 index a118555b6b6..00000000000 --- a/Tribler/Core/Modules/channel/channel.py +++ /dev/null @@ -1,130 +0,0 @@ -import codecs -import collections -import logging -import os -from binascii import hexlify -from twisted.internet import reactor -from twisted.internet.defer import DeferredList - -from Tribler.Core.Modules.channel.channel_rss import ChannelRssParser -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.simpledefs import SIGNAL_CHANNEL, SIGNAL_ON_CREATED, SIGNAL_RSS_FEED, SIGNAL_ON_UPDATED -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - - -class ChannelObject(TaskManager): - - def __init__(self, session, channel_community, is_created=False): - super(ChannelObject, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - - self._session = session - self._channel_community = channel_community - self._is_created = is_created - self._rss_feed_dict = collections.OrderedDict() - - rss_name = u"channel_rss_%s.json" % hexlify(self._channel_community.cid) - self._rss_file_path = os.path.join(self._session.config.get_state_dir(), rss_name) - - @property - def channel_id(self): - return self._channel_community.get_channel_id() - - @property - def name(self): - return self._channel_community.get_channel_name() - - @property - def description(self): - return self._channel_community.get_channel_description() - - @property - def mode(self): - return self._channel_community.get_channel_mode() - - def get_rss_feed_url_list(self): - return [url for url in self._rss_feed_dict.iterkeys()] - - def refresh_all_feeds(self): - deferreds = [feed.parse_feed() for feed in self._rss_feed_dict.itervalues()] - return DeferredList(deferreds, consumeErrors=True) - - def initialize(self): - # load existing rss_feeds - if os.path.exists(self._rss_file_path): - self._logger.debug(u"loading existing channel rss list from %s...", self._rss_file_path) - - with codecs.open(self._rss_file_path, 'rb', encoding='utf8') as f: - rss_list = json.load(f) - for rss_url in rss_list: - self._rss_feed_dict[rss_url] = None - - if self._is_created: - # create rss-parsers - for rss_feed_url in self._rss_feed_dict: - rss_parser = ChannelRssParser(self._session, self._channel_community, rss_feed_url) - rss_parser.initialize() - self._rss_feed_dict[rss_feed_url] = rss_parser - else: - # subscribe to the channel creation event - self._session.add_observer(self._on_channel_created, SIGNAL_CHANNEL, [SIGNAL_ON_CREATED]) - - def shutdown(self): - self.shutdown_task_manager() - for key, rss_parser in self._rss_feed_dict.iteritems(): - if rss_parser is not None: - rss_parser.shutdown() - self._rss_feed_dict = None - self._channel_community = None - self._session = None - - def _on_channel_created(self, subject, change_type, object_id, channel_data): - if channel_data[u'channel'].cid != self._channel_community.cid: - return - - def _create_rss_feed(channel_date): - self._is_created = True - - # create rss feed parsers - self._logger.debug(u"channel %s %s created", self.name, hexlify(self._channel_community.cid)) - for rss_feed_url in self._rss_feed_dict: - assert self._rss_feed_dict[rss_feed_url] is None - rss_parser = ChannelRssParser(self._session, self._channel_community, rss_feed_url) - rss_parser.initialize() - self._rss_feed_dict[rss_feed_url] = rss_parser - - task_name = u'create_rss_%s' % hexlify(channel_data[u'channel'].cid) - self.register_task(task_name, reactor.callLater(0, _create_rss_feed, channel_data)) - - def create_rss_feed(self, rss_feed_url): - if rss_feed_url in self._rss_feed_dict: - self._logger.warn(u"skip existing rss feed: %s", repr(rss_feed_url)) - return - - if not self._is_created: - # append the rss url if the channel has not been created yet - self._rss_feed_dict[rss_feed_url] = None - else: - # create an rss feed parser for this - rss_parser = ChannelRssParser(self._session, self._channel_community, rss_feed_url) - rss_parser.initialize() - self._rss_feed_dict[rss_feed_url] = rss_parser - - # flush the rss_feed_url to json file - with codecs.open(self._rss_file_path, 'wb', encoding='utf8') as f: - rss_list = [rss_url for rss_url in self._rss_feed_dict.iterkeys()] - json.dump(rss_list, f) - - def remove_rss_feed(self, rss_feed_url): - if rss_feed_url not in self._rss_feed_dict: - self._logger.warn(u"skip existing rss feed: %s", repr(rss_feed_url)) - return - - rss_parser = self._rss_feed_dict[rss_feed_url] - if rss_parser is not None: - rss_parser.shutdown() - del self._rss_feed_dict[rss_feed_url] - - rss_feed_data = {u'channel': self._channel_community, - u'rss_feed_url': rss_feed_url} - self._session.notifier.notify(SIGNAL_RSS_FEED, SIGNAL_ON_UPDATED, None, rss_feed_data) diff --git a/Tribler/Core/Modules/channel/channel_manager.py b/Tribler/Core/Modules/channel/channel_manager.py deleted file mode 100644 index 472dcb7a557..00000000000 --- a/Tribler/Core/Modules/channel/channel_manager.py +++ /dev/null @@ -1,127 +0,0 @@ -from __future__ import absolute_import - -import logging -from binascii import hexlify - -from six import string_types - -from Tribler.Core.Modules.channel.channel import ChannelObject -from Tribler.Core.exceptions import DuplicateChannelNameError -from Tribler.community.channel.community import ChannelCommunity -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - - -class ChannelManager(TaskManager): - """ - The Manager class that handles the Channels owned by ourselves. - It supports multiple-Channel creation and RSS feed. - """ - - def __init__(self, session): - super(ChannelManager, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - self.session = session - self.dispersy = None - - self._channel_mode_map = {u'open': ChannelCommunity.CHANNEL_OPEN, - u'semi-open': ChannelCommunity.CHANNEL_SEMI_OPEN, - u'closed': ChannelCommunity.CHANNEL_CLOSED, - } - - self._channel_list = [] - - def initialize(self): - self.dispersy = self.session.get_dispersy_instance() - - # get all channels owned by me - from Tribler.community.channel.community import ChannelCommunity - for community in self.session.lm.dispersy.get_communities(): - if isinstance(community, ChannelCommunity) and community.master_member and community.master_member.private_key: - channel_obj = ChannelObject(self.session, community, is_created=True) - channel_obj.initialize() - self._channel_list.append(channel_obj) - - self._logger.debug(u"loaded channel '%s', %s", channel_obj.name, hexlify(community.cid)) - - def shutdown(self): - self.shutdown_task_manager() - self._channel_mode_map = None - - for channel_object in self._channel_list: - channel_object.shutdown() - self._channel_list = None - - self.dispersy = None - self.session = None - - def create_channel(self, name, description, mode, rss_url=None): - """ - Creates a new Channel. - :param name: Name of the Channel. - :param description: Description of the Channel. - :param mode: Mode of the Channel ('open', 'semi-open', or 'closed'). - :param rss_url: RSS URL for the Channel. - :return: Channel ID - :raises DuplicateChannelNameError if name already exists - """ - assert isinstance(name, string_types), u"name is not a string_types: %s" % type(name) - assert isinstance(description, string_types), u"description is not a string_types: %s" % type(description) - assert mode in self._channel_mode_map, u"invalid mode: %s" % mode - assert isinstance(rss_url, string_types) or rss_url is None, (u"rss_url is not a string_types or None: %s" - % type(rss_url)) - - # if two channels have the same name, this will not work - for channel_object in self._channel_list: - if name == channel_object.name: - raise DuplicateChannelNameError(u"Channel name already exists: %s" % name) - - channel_mode = self._channel_mode_map[mode] - community = ChannelCommunity.create_community(self.dispersy, self.session.dispersy_member, - tribler_session=self.session) - - channel_obj = ChannelObject(self.session, community) - channel_obj.initialize() - - community.set_channel_mode(channel_mode) - community.create_channel(name, description) - - # create channel object - self._channel_list.append(channel_obj) - - if rss_url is not None: - channel_obj.create_rss_feed(rss_url) - - self._logger.debug(u"creating channel '%s', %s", channel_obj.name, hexlify(community.cid)) - return channel_obj.channel_id - - def get_my_channel(self, channel_id): - """ - Gets the ChannelObject with the given channel id. - :return: The ChannelObject if exists, otherwise None. - """ - channel_object = None - for obj in self._channel_list: - if obj.channel_id == channel_id: - channel_object = obj - break - return channel_object - - def get_channel(self, name): - """ - Gets a Channel by name. - :param name: Channel name. - :return: The channel object if exists, otherwise None. - """ - channel_object = None - for obj in self._channel_list: - if obj.name == name: - channel_object = obj - break - return channel_object - - def get_channel_list(self): - """ - Gets a list of all channel objects. - :return: The list of all channel objects. - """ - return self._channel_list diff --git a/Tribler/Core/Modules/channel/channel_rss.py b/Tribler/Core/Modules/channel/channel_rss.py deleted file mode 100644 index e220844ef61..00000000000 --- a/Tribler/Core/Modules/channel/channel_rss.py +++ /dev/null @@ -1,268 +0,0 @@ -import hashlib -import logging -import os -import re -import time -from binascii import hexlify - -import feedparser -from twisted.internet import reactor -from twisted.internet.defer import DeferredList, succeed -from twisted.web.client import getPage - -from Tribler.Core.Modules.channel.cache import SimpleCache -from Tribler.Core.TorrentDef import TorrentDef -import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Utilities.utilities import http_get -from Tribler.Core.simpledefs import (SIGNAL_CHANNEL_COMMUNITY, SIGNAL_ON_TORRENT_UPDATED, SIGNAL_RSS_FEED, - SIGNAL_ON_UPDATED) -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - -try: - long # pylint: disable=long-builtin -except NameError: - long = int # pylint: disable=redefined-builtin - -DEFAULT_CHECK_INTERVAL = 1800 # half an hour - - -class ChannelRssParser(TaskManager): - - def __init__(self, session, channel_community, rss_url, check_interval=DEFAULT_CHECK_INTERVAL): - super(ChannelRssParser, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - - self.session = session - self.channel_community = channel_community - self.rss_url = rss_url - self.check_interval = check_interval - - self._url_cache = None - - self._pending_metadata_requests = {} - - self._to_stop = False - - self.running = False - - def initialize(self): - # initialize URL cache - # use the SHA1 of channel cid + rss_url as key - cache_key = hashlib.sha1(self.channel_community.cid) - cache_key.update(self.rss_url) - cache_key_str = hexlify(cache_key.digest()) - self._logger.debug(u"using key %s for channel %s, rss %s", - cache_key_str, hexlify(self.channel_community.cid), self.rss_url) - - url_cache_name = u"rss_cache_%s.txt" % cache_key_str - url_cache_path = os.path.join(self.session.config.get_state_dir(), url_cache_name) - self._url_cache = SimpleCache(url_cache_path) - self._url_cache.load() - - # schedule the scraping task - self.register_task(u"rss_scrape", - reactor.callLater(2, self._task_scrape)) - - # subscribe to channel torrent creation - self.session.notifier.add_observer(self.on_channel_torrent_created, SIGNAL_CHANNEL_COMMUNITY, - [SIGNAL_ON_TORRENT_UPDATED], self.channel_community.get_channel_id()) - - # notify that a RSS feed has been created - rss_feed_data = {u'channel': self.channel_community, - u'rss_feed_url': self.rss_url} - self.session.notifier.notify(SIGNAL_RSS_FEED, SIGNAL_ON_UPDATED, None, rss_feed_data) - self.running = True - - def shutdown(self): - self._to_stop = True - self.shutdown_task_manager() - - self._url_cache.save() - self._url_cache = None - - self.channel_community = None - self.session = None - self.running = False - - def parse_feed(self): - rss_parser = RSSFeedParser() - - def on_rss_items(rss_items): - if not rss_items: - self._logger.warning(u"No RSS items found.") - return succeed(None) - - def_list = [] - for rss_item in rss_items: - if self._to_stop: - continue - - torrent_url = rss_item[u'torrent_url'].encode('utf-8') - if torrent_url.startswith('magnet:'): - self._logger.warning(u"Tribler does not support adding magnet links to a channel from a RSS feed.") - continue - - torrent_deferred = getPage(torrent_url) - torrent_deferred.addCallbacks(lambda t, r=rss_item: self.on_got_torrent(t, rss_item=r), - self.on_got_torrent_error) - def_list.append(torrent_deferred) - - return DeferredList(def_list, consumeErrors=True) - - return rss_parser.parse(self.rss_url, self._url_cache).addCallback(on_rss_items) - - def _task_scrape(self): - deferred = self.parse_feed() - - if not self._to_stop: - # schedule the next scraping task - self._logger.info(u"Finish scraping %s, schedule task after %s", self.rss_url, self.check_interval) - self.register_task(u'rss_scrape', - reactor.callLater(self.check_interval, self._task_scrape)) - - return deferred - - def on_got_torrent(self, torrent_data, rss_item=None): - if self._to_stop: - return - - # save torrent - tdef = TorrentDef.load_from_memory(torrent_data) - self.session.lm.rtorrent_handler.save_torrent(tdef) - - # add metadata pending request - info_hash = tdef.get_infohash() - if u'thumbnail_list' in rss_item and rss_item[u'thumbnail_list']: - # only use the first thumbnail - rss_item[u'thumbnail_url'] = rss_item[u'thumbnail_list'][0] - if info_hash not in self._pending_metadata_requests: - self._pending_metadata_requests[info_hash] = rss_item - - # create channel torrent - self.channel_community._disp_create_torrent_from_torrentdef(tdef, long(time.time())) - - # update URL cache - self._url_cache.add(rss_item[u'torrent_url']) - self._url_cache.save() - - self._logger.info(u"Channel torrent %s created", tdef.get_name_as_unicode()) - - def on_got_torrent_error(self, failure): - """ - This callback is invoked when the lookup for a specific torrent failed. - """ - self._logger.warning(u"Failed to fetch torrent info from RSS feed: %s", failure) - - def on_channel_torrent_created(self, subject, events, object_id, data_list): - if self._to_stop: - return - - for data in data_list: - if data[u'info_hash'] in self._pending_metadata_requests: - rss_item = self._pending_metadata_requests.pop(data[u'info_hash']) - rss_item[u'info_hash'] = data[u'info_hash'] - rss_item[u'channel_torrent_id'] = data[u'channel_torrent_id'] - - metadata_deferred = getPage(rss_item[u'thumbnail_url'].encode('utf-8')) - metadata_deferred.addCallback(lambda md, r=rss_item: self.on_got_metadata(md, rss_item=r)) - - def on_got_metadata(self, metadata_data, rss_item=None): - # save metadata - thumb_hash = hashlib.sha1(metadata_data).digest() - self.session.lm.rtorrent_handler.save_metadata(thumb_hash, metadata_data) - - # create modification message for channel - modifications = {u'metadata-json': json.dumps({u'title': rss_item['title'][:64], - u'description': rss_item['description'][:768], - u'thumb_hash': thumb_hash.encode('hex')})} - self.channel_community.modifyTorrent(rss_item[u'channel_torrent_id'], modifications) - - -class RSSFeedParser(object): - - def __init__(self): - self._logger = logging.getLogger(self.__class__.__name__) - - def _parse_html(self, content): - """ - Parses an HTML content and find links. - """ - if content is None: - return None - url_set = set() - - a_list = re.findall(r']+)', content) - for a_href in a_list: - url_set.add(a_href) - - img_list = re.findall(r']+)', content) - for img_src in img_list: - url_set.add(img_src) - - return url_set - - def _html2plaintext(self, html_content): - """ - Converts an HTML document to plain text. - """ - content = html_content.replace('\r\n', '\n') - - content = re.sub('', '\n', content) - content = re.sub('', '\n', content) - - content = re.sub('

', '', content) - content = re.sub('

', '\n', content) - - content = re.sub('<.+/>', '', content) - content = re.sub('<.+>', '', content) - content = re.sub('', '', content) - - content = re.sub('[\n]+', '\n', content) - content = re.sub('[ \t\v\f]+', ' ', content) - - parsed_html_content = u'' - for line in content.split('\n'): - trimmed_line = line.strip() - if trimmed_line: - parsed_html_content += trimmed_line + u'\n' - - return parsed_html_content - - def parse(self, url, cache): - """ - Parses a RSS feed. This methods supports RSS 2.0 and Media RSS. - """ - def on_rss_response(response): - feed = feedparser.parse(response) - feed_items = [] - - for item in feed.entries: - # ignore the ones that we have seen before - link = item.get(u'link', None) - if link is None or cache.has(link): - continue - - title = self._html2plaintext(item[u'title']).strip() - description = self._html2plaintext(item.get(u'media_description', u'')).strip() - torrent_url = item[u'link'] - - thumbnail_list = [] - media_thumbnail_list = item.get(u'media_thumbnail', None) - if media_thumbnail_list: - for thumbnail in media_thumbnail_list: - thumbnail_list.append(thumbnail[u'url']) - - # assemble the information - parsed_item = {u'title': title, - u'description': description, - u'torrent_url': torrent_url, - u'thumbnail_list': thumbnail_list} - - feed_items.append(parsed_item) - - return feed_items - - def on_rss_error(failure): - self._logger.error("Error when fetching RSS feed: %s", failure) - - return http_get(str(url)).addCallbacks(on_rss_response, on_rss_error) diff --git a/Tribler/Core/Modules/restapi/downloads_endpoint.py b/Tribler/Core/Modules/restapi/downloads_endpoint.py index 962071c222b..f9334ec40f3 100644 --- a/Tribler/Core/Modules/restapi/downloads_endpoint.py +++ b/Tribler/Core/Modules/restapi/downloads_endpoint.py @@ -2,11 +2,15 @@ import logging +from libtorrent import bencode, create_torrent + from pony.orm import db_session -from six import text_type + +import six from six import unichr # pylint: disable=redefined-builtin from six.moves.urllib.parse import unquote_plus from six.moves.urllib.request import url2pathname + from twisted.web import http, resource from twisted.web.server import NOT_DONE_YET @@ -14,9 +18,10 @@ from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.restapi.util import return_handled_exception +from Tribler.Core.Utilities.torrent_utils import get_info_from_handle from Tribler.Core.Utilities.utilities import unichar_string from Tribler.Core.exceptions import InvalidSignatureException -from Tribler.Core.simpledefs import DOWNLOAD, UPLOAD, dlstatus_strings, DLMODE_VOD +from Tribler.Core.simpledefs import DLMODE_VOD, DOWNLOAD, UPLOAD, dlstatus_strings from Tribler.pyipv8.ipv8.database import database_blob from Tribler.util import cast_to_unicode_utf8 @@ -87,11 +92,11 @@ def create_dconfig_from_params(parameters): download_config.set_safe_seeding(True) if 'destination' in parameters and len(parameters['destination']) > 0: - dest_dir = unicode(parameters['destination'][0], 'utf-8') + dest_dir = cast_to_unicode_utf8(parameters['destination'][0]) download_config.set_dest_dir(dest_dir) if 'selected_files[]' in parameters: - selected_files_list = [unicode(f, 'utf-8') for f in parameters['selected_files[]']] + selected_files_list = [cast_to_unicode_utf8(f) for f in parameters['selected_files[]']] download_config.set_selected_files(selected_files_list) return download_config, None @@ -336,7 +341,7 @@ def on_error(error): uri = parameters['uri'][0] if uri.startswith("file:"): if uri.endswith(".mdblob"): - filename = url2pathname(uri[5:].encode('utf-8') if isinstance(uri, text_type) else uri[5:]) + filename = url2pathname(uri[5:].encode('utf-8') if isinstance(uri, six.text_type) else uri[5:]) try: payload = ChannelMetadataPayload.from_file(filename) except IOError: @@ -348,7 +353,7 @@ def on_error(error): with db_session: channel = self.session.lm.mds.process_payload(payload) - if channel and not channel.subscribed and channel.local_version < channel.version: + if channel and not channel.subscribed and channel.local_version < channel.timestamp: channel.subscribed = True download, _ = self.session.lm.gigachannel_manager.download_channel(channel) else: @@ -552,13 +557,21 @@ def render_GET(self, request): The contents of the .torrent file. """ - torrent = self.session.get_collected_torrent(self.infohash) - if not torrent: - return DownloadExportTorrentEndpoint.return_404(request) + download = self.session.get_download(self.infohash) + if not download: + return DownloadSpecificEndpoint.return_404(request) + + if not download.handle or not download.handle.is_valid() or not download.handle.has_metadata(): + return DownloadSpecificEndpoint.return_404(request) + + torrent_info = get_info_from_handle(download.handle) + t = create_torrent(torrent_info) + torrent = t.generate() + bencoded_torrent = bencode(torrent) request.setHeader(b'content-type', 'application/x-bittorrent') request.setHeader(b'Content-Disposition', 'attachment; filename=%s.torrent' % self.infohash.encode('hex')) - return torrent + return bencoded_torrent class DownloadFilesEndpoint(DownloadBaseEndpoint): diff --git a/Tribler/Core/Modules/restapi/events_endpoint.py b/Tribler/Core/Modules/restapi/events_endpoint.py index 1959e0cc4db..889867139e7 100644 --- a/Tribler/Core/Modules/restapi/events_endpoint.py +++ b/Tribler/Core/Modules/restapi/events_endpoint.py @@ -1,11 +1,11 @@ +from __future__ import absolute_import + from twisted.web import server, resource -from Tribler.Core.Modules.restapi.util import convert_db_channel_to_json, convert_search_torrent_to_json, \ - fix_unicode_dict -from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, SIGNAL_TORRENT, - NTFY_UPGRADER, NTFY_STARTED, NTFY_WATCH_FOLDER_CORRUPT_TORRENT, NTFY_INSERT, +from Tribler.Core.Modules.restapi.util import fix_unicode_dict +from Tribler.Core.simpledefs import (NTFY_UPGRADER, NTFY_STARTED, NTFY_WATCH_FOLDER_CORRUPT_TORRENT, NTFY_INSERT, NTFY_NEW_VERSION, NTFY_FINISHED, NTFY_TRIBLER, NTFY_UPGRADER_TICK, NTFY_CHANNEL, - NTFY_DISCOVERED, NTFY_TORRENT, NTFY_ERROR, NTFY_DELETE, NTFY_MARKET_ON_ASK, + NTFY_DISCOVERED, NTFY_TORRENT, NTFY_ERROR, NTFY_MARKET_ON_ASK, NTFY_UPDATE, NTFY_MARKET_ON_BID, NTFY_MARKET_ON_TRANSACTION_COMPLETE, NTFY_MARKET_ON_ASK_TIMEOUT, NTFY_MARKET_ON_BID_TIMEOUT, NTFY_MARKET_ON_PAYMENT_RECEIVED, NTFY_MARKET_ON_PAYMENT_SENT, @@ -65,8 +65,6 @@ def __init__(self, session): self.infohashes_sent = set() self.channel_cids_sent = set() - self.session.add_observer(self.on_search_results_channels, SIGNAL_CHANNEL, [SIGNAL_ON_SEARCH_RESULTS]) - self.session.add_observer(self.on_search_results_torrents, SIGNAL_TORRENT, [SIGNAL_ON_SEARCH_RESULTS]) self.session.add_observer(self.on_upgrader_started, NTFY_UPGRADER, [NTFY_STARTED]) self.session.add_observer(self.on_upgrader_finished, NTFY_UPGRADER, [NTFY_FINISHED]) self.session.add_observer(self.on_upgrader_tick, NTFY_UPGRADER_TICK, [NTFY_STARTED]) @@ -105,46 +103,6 @@ def write_data(self, message): else: [request.write(message_str + '\n') for request in self.events_requests] - def start_new_query(self): - self.infohashes_sent = set() - self.channel_cids_sent = set() - - def on_search_results_channels(self, subject, changetype, objectID, results): - """ - Returns the channel search results over the events endpoint. - """ - query = ' '.join(results['keywords']) - - for channel in results['result_list']: - channel_json = convert_db_channel_to_json(channel, include_rel_score=True) - - if self.session.config.get_family_filter_enabled() and \ - self.session.lm.category.xxx_filter.isXXX(channel_json['name']): - continue - - if channel_json['dispersy_cid'] not in self.channel_cids_sent: - self.write_data({"type": "search_result_channel", "event": {"query": query, "result": channel_json}}) - self.channel_cids_sent.add(channel_json['dispersy_cid']) - - def on_search_results_torrents(self, subject, changetype, objectID, results): - """ - Returns the torrent search results over the events endpoint. - """ - query = ' '.join(results['keywords']) - - for torrent in results['result_list']: - torrent_json = convert_search_torrent_to_json(torrent) - torrent_name = torrent_json['name'] - torrent_json['relevance_score'] = torrent_json['relevance_score'] if 'relevance_score' in torrent_json \ - else self.session.lm.torrent_db.relevance_score_remote_torrent(torrent_name) - - if self.session.config.get_family_filter_enabled() and torrent_json['category'] == 'xxx': - continue - - if 'infohash' in torrent_json and torrent_json['infohash'] not in self.infohashes_sent: - self.write_data({"type": "search_result_torrent", "event": {"query": query, "result": torrent_json}}) - self.infohashes_sent.add(torrent_json['infohash']) - def on_upgrader_started(self, subject, changetype, objectID, *args): self.write_data({"type": "upgrader_started"}) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index b6b3e3d3301..4805fcb1c5d 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import json import logging from binascii import unhexlify @@ -242,8 +244,7 @@ class TorrentHealthEndpoint(resource.Resource): def __init__(self, session, infohash): resource.Resource.__init__(self) self.session = session - self.infohash = infohash - self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) + self.infohash = unhexlify(infohash) self._logger = logging.getLogger(self.__class__.__name__) def finish_request(self, request): @@ -292,20 +293,10 @@ def render_GET(self, request): if 'refresh' in request.args and request.args['refresh'] and request.args['refresh'][0] == "1": refresh = True - torrent_db_columns = ['C.torrent_id', 'num_seeders', 'num_leechers', 'next_tracker_check'] - torrent_info = self.torrent_db.getTorrent(self.infohash.decode('hex'), torrent_db_columns) - def on_health_result(result): request.write(json.dumps({'health': result})) self.finish_request(request) - def on_magnet_timeout_error(_): - if not request.finished: - request.setResponseCode(http.NOT_FOUND) - request.write(json.dumps({"error": "torrent not found in database"})) - if not request.finished: - self.finish_request(request) - def on_request_error(failure): if not request.finished: request.setResponseCode(http.BAD_REQUEST) @@ -314,42 +305,14 @@ def on_request_error(failure): if not request.finished: self.finish_request(request) - def make_torrent_health_request(): - self.session.check_torrent_health(self.infohash.decode('hex'), timeout=timeout, scrape_now=refresh) \ - .addCallback(on_health_result).addErrback(on_request_error) - - magnet = None - if torrent_info is None: - # Maybe this is a chant torrent? - infohash = self.infohash.decode('hex') - with db_session: - md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: - g.infohash == database_blob(infohash))) - if md_list: - torrent_md = md_list[0] # Any MD containing this infohash is fine - magnet = torrent_md.get_magnet() - if 'timeout' in request.args: - timeout = int(request.args['timeout'][0]) - else: - timeout = 50 - - def _add_torrent_and_check(metainfo): - tdef = TorrentDef.load_from_dict(metainfo) - assert (tdef.infohash == infohash), "DHT infohash does not match locally generated one" - self._logger.info("Chant-managed torrent fetched from DHT. Adding it to local cache, %s", self.infohash) - self.session.lm.torrent_db.addExternalTorrent(tdef) - self.session.lm.torrent_db._db.commit_now() - make_torrent_health_request() - - if magnet: - # Try to get the torrent from DHT and add it to the local cache - self._logger.info("Chant-managed torrent not in cache. Going to fetch it from DHT, %s", self.infohash) - self.session.lm.ltmgr.get_metainfo(magnet, callback=_add_torrent_and_check, - timeout=timeout, timeout_callback=on_magnet_timeout_error, notify=False) - elif torrent_info is None: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "torrent not found in database"}) - else: - make_torrent_health_request() + with db_session: + md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: + g.infohash == database_blob(self.infohash))) + if not md_list: + request.setResponseCode(http.NOT_FOUND) + request.write(json.dumps({"error": "torrent not found in database"})) + + self.session.check_torrent_health(self.infohash, timeout=timeout, scrape_now=refresh) \ + .addCallback(on_health_result).addErrback(on_request_error) return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index 8af5157a7cc..4f494d996e4 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import json import os from binascii import unhexlify, hexlify diff --git a/Tribler/Core/Modules/restapi/root_endpoint.py b/Tribler/Core/Modules/restapi/root_endpoint.py index cd3d60f3e58..e2ec46a4c05 100644 --- a/Tribler/Core/Modules/restapi/root_endpoint.py +++ b/Tribler/Core/Modules/restapi/root_endpoint.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + from twisted.web import resource from Tribler.Core.Modules.restapi.create_torrent_endpoint import CreateTorrentEndpoint @@ -13,6 +15,7 @@ from Tribler.Core.Modules.restapi.shutdown_endpoint import ShutdownEndpoint from Tribler.Core.Modules.restapi.state_endpoint import StateEndpoint from Tribler.Core.Modules.restapi.statistics_endpoint import StatisticsEndpoint +from Tribler.Core.Modules.restapi.torrentinfo_endpoint import TorrentInfoEndpoint from Tribler.Core.Modules.restapi.trustchain_endpoint import TrustchainEndpoint from Tribler.Core.Modules.restapi.wallets_endpoint import WalletsEndpoint from Tribler.pyipv8.ipv8.REST.root_endpoint import RootEndpoint as IPV8RootEndpoint @@ -54,6 +57,7 @@ def start_endpoints(self): "market": MarketEndpoint, "wallets": WalletsEndpoint, "libtorrent": LibTorrentEndpoint, + "torrentinfo": TorrentInfoEndpoint, "metadata": MetadataEndpoint, "mychannel": MyChannelEndpoint, "search": SearchEndpoint diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 2122805dc88..589dea73ede 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -1,7 +1,6 @@ from __future__ import absolute_import import logging -from binascii import unhexlify from pony.orm import db_session @@ -21,8 +20,6 @@ def __init__(self, session): resource.Resource.__init__(self) self.session = session self.events_endpoint = None - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - self.torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) self._logger = logging.getLogger(self.__class__.__name__) self.putChild("completions", SearchCompletionsEndpoint(session)) diff --git a/Tribler/Core/Modules/restapi/statistics_endpoint.py b/Tribler/Core/Modules/restapi/statistics_endpoint.py index 0138ff02a31..c822eb1e065 100644 --- a/Tribler/Core/Modules/restapi/statistics_endpoint.py +++ b/Tribler/Core/Modules/restapi/statistics_endpoint.py @@ -13,9 +13,7 @@ def __init__(self, session): child_handler_dict = { "tribler": StatisticsTriblerEndpoint, - "dispersy": StatisticsDispersyEndpoint, "ipv8": StatisticsIPv8Endpoint, - "communities": StatisticsCommunitiesEndpoint } for path, child_cls in child_handler_dict.iteritems(): @@ -65,50 +63,6 @@ def render_GET(self, request): return json.dumps({'tribler_statistics': self.session.get_tribler_statistics()}) -class StatisticsDispersyEndpoint(resource.Resource): - """ - This class handles requests regarding Dispersy statistics. - """ - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - - def render_GET(self, request): - """ - .. http:get:: /statistics/dispersy - - A GET request to this endpoint returns general statistics in Dispersy. - The returned runtime is the amount of seconds that Dispersy is active. The total uploaded and total downloaded - statistics are in bytes. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/statistics/dispersy - - **Example response**: - - .. sourcecode:: javascript - - { - "dispersy_statistics": { - "wan_address": "123.321.456.654:1234", - "lan_address": "192.168.1.2:1435", - "connection": "unknown", - "runtime": 859.34, - "total_downloaded": 538.53, - "total_uploaded": 983.24, - "packets_sent": 43, - "packets_received": 89, - ... - } - } - """ - return json.dumps({'dispersy_statistics': self.session.get_dispersy_statistics()}) - - class StatisticsIPv8Endpoint(resource.Resource): """ This class handles requests regarding IPv8 statistics. @@ -144,47 +98,3 @@ def render_GET(self, request): return json.dumps({ 'ipv8_statistics': self.session.get_ipv8_statistics() }) - - -class StatisticsCommunitiesEndpoint(resource.Resource): - """ - This class handles requests regarding Dispersy communities statistics. - """ - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - - def render_GET(self, request): - """ - .. http:get:: /statistics/communities - - A GET request to this endpoint returns general statistics of active Dispersy communities. - - **Example request**: - - .. sourcecode:: none - - curl -X GET http://localhost:8085/statistics/communities - - **Example response**: - - .. sourcecode:: javascript - - { - "dispersy_community_statistics": [{ - "identifier": "48d04e922dec4430daf22400c9d4cc5a3a53b27d", - "member": "a66ebac9d88a239ef348a030d5ed3837868fc06d", - "candidates": 43, - "global_time": 42, - "classification", "ChannelCommunity", - "packets_sent": 43, - "packets_received": 89, - ... - }, { ... }] - } - """ - return json.dumps({ - 'dispersy_community_statistics': self.session.get_dispersy_community_statistics(), - 'ipv8_overlay_statistics': self.session.get_ipv8_overlay_statistics() - }) diff --git a/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py b/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py new file mode 100644 index 00000000000..d1abc1b9e67 --- /dev/null +++ b/Tribler/Core/Modules/restapi/torrentinfo_endpoint.py @@ -0,0 +1,157 @@ +from __future__ import absolute_import + +import hashlib +import logging + +from libtorrent import bdecode, bencode + +from six import text_type +from six.moves.urllib.request import url2pathname + +from twisted.internet.defer import Deferred +from twisted.internet.error import ConnectError, ConnectionLost, DNSLookupError +from twisted.web import http, resource +from twisted.web.server import NOT_DONE_YET + +import Tribler.Core.Utilities.json_util as json +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, \ + REGULAR_TORRENT, read_payload +from Tribler.Core.Utilities.utilities import fix_torrent, http_get, parse_magnetlink, unichar_string +from Tribler.Core.exceptions import HttpError, InvalidSignatureException +from Tribler.util import cast_to_unicode_utf8 + + +class TorrentInfoEndpoint(resource.Resource): + """ + This endpoint is responsible for handing all requests regarding torrent info in Tribler. + """ + + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + self._logger = logging.getLogger(self.__class__.__name__) + + def finish_request(self, request): + try: + request.finish() + except RuntimeError: + self._logger.warning("Writing response failed, probably the client closed the connection already.") + + def render_GET(self, request): + """ + .. http:get:: /torrentinfo + + A GET request to this endpoint will return information from a torrent found at a provided URI. + This URI can either represent a file location, a magnet link or a HTTP(S) url. + - torrent: the URI of the torrent file that should be downloaded. This parameter is required. + + **Example request**: + + .. sourcecode:: none + + curl -X PUT http://localhost:8085/torrentinfo?torrent=file:/home/me/test.torrent + + **Example response**: + + .. sourcecode:: javascript + + {"metainfo": } + """ + + def on_got_metainfo(metainfo): + if not isinstance(metainfo, dict) or 'info' not in metainfo: + self._logger.warning("Received metainfo is not a valid dictionary") + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + request.write(json.dumps({"error": 'invalid response'})) + self.finish_request(request) + return + + # TODO(Martijn): store the stuff in a database!!! + infohash = hashlib.sha1(bencode(metainfo['info'])).digest() + + # Check if the torrent is already in the downloads + metainfo['download_exists'] = infohash in self.session.lm.downloads + + request.write(json.dumps({"metainfo": metainfo}, ensure_ascii=False)) + self.finish_request(request) + + def on_metainfo_timeout(_): + if not request.finished: + request.setResponseCode(http.REQUEST_TIMEOUT) + request.write(json.dumps({"error": "timeout"})) + # If the above request.write failed, the request will have already been finished + if not request.finished: + self.finish_request(request) + + def on_lookup_error(failure): + failure.trap(ConnectError, DNSLookupError, HttpError, ConnectionLost) + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + request.write(json.dumps({"error": unichar_string(failure.getErrorMessage())})) + self.finish_request(request) + + def _on_loaded(response): + if response.startswith('magnet'): + _, infohash, _ = parse_magnetlink(response) + if infohash: + self.session.lm.ltmgr.get_metainfo(response, callback=metainfo_deferred.callback, timeout=20, + timeout_callback=on_metainfo_timeout, notify=True) + return + metainfo_deferred.callback(bdecode(response)) + + def on_mdblob(filename): + try: + with open(filename, 'rb') as f: + serialized_data = f.read() + payload = read_payload(serialized_data) + if payload.metadata_type not in [REGULAR_TORRENT, CHANNEL_TORRENT]: + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "Non-torrent metadata type"}) + magnet = payload.get_magnet() + except InvalidSignatureException: + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "metadata has incorrect signature"}) + else: + return on_magnet(magnet) + + def on_file(): + try: + filename = url2pathname(uri[5:].encode('utf-8') if isinstance(uri, text_type) else uri[5:]) + if filename.endswith(BLOB_EXTENSION): + return on_mdblob(filename) + metainfo_deferred.callback(bdecode(fix_torrent(filename))) + return NOT_DONE_YET + except TypeError: + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "error while decoding torrent file"}) + + def on_magnet(mlink=None): + infohash = parse_magnetlink(mlink or uri)[1] + if infohash is None: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "missing infohash"}) + + self.session.lm.ltmgr.get_metainfo(mlink or uri, callback=metainfo_deferred.callback, timeout=20, + timeout_callback=on_metainfo_timeout, notify=True) + return NOT_DONE_YET + + metainfo_deferred = Deferred() + metainfo_deferred.addCallback(on_got_metainfo) + + if 'uri' not in request.args or not request.args['uri']: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "uri parameter missing"}) + + uri = cast_to_unicode_utf8(request.args['uri'][0]) + + if uri.startswith('file:'): + return on_file() + elif uri.startswith('http'): + http_get(uri.encode('utf-8')).addCallback(_on_loaded).addErrback(on_lookup_error) + elif uri.startswith('magnet'): + return on_magnet() + else: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "invalid uri"}) + + return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/util.py b/Tribler/Core/Modules/restapi/util.py index e5e7d4a0368..ede4c8bbfc1 100644 --- a/Tribler/Core/Modules/restapi/util.py +++ b/Tribler/Core/Modules/restapi/util.py @@ -12,12 +12,6 @@ from twisted.web import http import Tribler.Core.Utilities.json_util as json -from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, time2int, int2time -from Tribler.Core.Modules.restapi import VOTE_SUBSCRIBE - -CATEGORY_OLD_CHANNEL = u'Old channel' -CATEGORY_CHANNEL = u'Channel' -CATEGORY_UNKNOWN = u'Unknown' def return_handled_exception(request, exception): @@ -36,130 +30,6 @@ def return_handled_exception(request, exception): }) -def convert_channel_metadata_to_tuple(metadata): - """ - Convert some given channel metadata to a tuple, similar to returned channels from the database. - :param metadata: The metadata to convert. - :return: A tuple with information about the torrent. - """ - # TODO: the values here are totally random temporary placeholders, and should be removed eventually. - votes = 1 - my_vote = 2 - spam = 0 - relevance = 0.9 - unix_timestamp = time2int(metadata.timestamp) - return metadata.rowid, str(metadata.public_key), metadata.title, metadata.tags, int(metadata.size), votes, spam, \ - my_vote, unix_timestamp, relevance, metadata.status, metadata.torrent_date, metadata.metadata_type - - -def convert_torrent_metadata_to_tuple(metadata): - """ - Convert some given torrent metadata to a tuple, similar to returned torrents from the database. - :param metadata: The metadata to convert. - :return: A tuple with information about the torrent. - """ - seeders = 0 - leechers = 0 - last_tracker_check = 0 - original_category = metadata.tags.split(' ', 1)[0] if metadata.tags else CATEGORY_UNKNOWN - category = CATEGORY_CHANNEL if metadata._discriminator_ == CHANNEL_TORRENT else original_category - infohash = str(metadata.infohash) - relevance = 0.9 - subscribed = '' - if metadata._discriminator_ == CHANNEL_TORRENT: - subscribed = 1 if metadata.subscribed else 0 - return (metadata.rowid, infohash, metadata.title, int(metadata.size), category, seeders, leechers, - last_tracker_check, None, relevance, metadata.status, metadata.torrent_date, metadata.metadata_type, - hexlify(metadata.public_key), subscribed) - - -def convert_search_torrent_to_json(torrent): - """ - Converts a given torrent to a JSON dictionary. Note that the torrent might be either a result from the local - database in which case it is a tuple or a remote search result in which case it is a dictionary. - """ - if isinstance(torrent, dict): - return convert_remote_torrent_to_json(torrent) - return convert_db_torrent_to_json(torrent, include_rel_score=True) - - -def convert_db_channel_to_json(channel, include_rel_score=False): - """ - This method converts a channel in the database to a JSON dictionary. - """ - res_json = {"id": channel[0], "dispersy_cid": hexlify(channel[1]), "name": channel[2], - "description": channel[3], "votes": channel[5], "torrents": channel[4], "spam": channel[6], - "modified": channel[8], "subscribed": (channel[7] == VOTE_SUBSCRIBE)} - - if include_rel_score: - res_json["relevance_score"] = channel[9] - - return res_json - - -def channel_to_torrent_adapter(channel): - return (channel[0], '', channel[2], channel[4], - CATEGORY_OLD_CHANNEL, 0, 0, - 0, - 0, - 0, - 0, - int2time(0), - CHANNEL_TORRENT, - hexlify(channel[1]), - int(channel[7] == VOTE_SUBSCRIBE)) - - -def convert_chant_channel_to_json(channel): - """ - This method converts a chant channel entry to a JSON dictionary. - """ - # TODO: this stuff is mostly placeholder, especially 'modified' field. Should be changed when Dispersy is out. - res_json = {"id": 0, "dispersy_cid": hexlify(channel.public_key), "name": channel.title, - "description": channel.tags, "votes": channel.votes, "torrents": channel.size, "spam": 0, - "modified": channel.version, "subscribed": channel.subscribed} - - return res_json - - -def convert_db_torrent_to_json(torrent, include_rel_score=False): - """ - This method converts a torrent in the database to a JSON dictionary. - """ - torrent_name = torrent[2] - if torrent_name is None or len(torrent_name.strip()) == 0: - torrent_name = "Unnamed torrent" - - res_json = {"id": torrent[0], "infohash": hexlify(torrent[1]), "name": torrent_name, "size": torrent[3] or 0, - "category": torrent[4] if torrent[4] else "unknown", "num_seeders": torrent[5] or 0, - "num_leechers": torrent[6] or 0, - "last_tracker_check": torrent[7] or 0, - "commit_status": torrent[10] if len(torrent) >= 11 else 0, - "date": str(time2int(torrent[11])) if len(torrent) >= 12 else 0, - "type": str('channel' if len(torrent) >= 13 and torrent[12] == CHANNEL_TORRENT else 'torrent'), - "public_key": str(torrent[13]) if len(torrent) >= 14 else '', - "relevance_score": torrent[9] if include_rel_score else 0, - "subscribed": str(torrent[14]) if len(torrent) >= 15 else '', - "health": HEALTH_GOOD if torrent[5] else HEALTH_UNCHECKED, - "dispersy_cid": str(torrent[13]) if len(torrent) >= 14 else '', - "votes": 0 - } - return res_json - - -def convert_remote_torrent_to_json(torrent): - """ - This method converts a torrent that has been received by remote peers in the network to a JSON dictionary. - """ - torrent_name = torrent['name'] - if torrent_name is None or len(torrent_name.strip()) == 0: - torrent_name = "Unnamed torrent" - - return {'id': torrent['torrent_id'], "infohash": hexlify(torrent['infohash']), "name": torrent_name, - 'size': torrent['length'], 'category': torrent['category'], 'num_seeders': torrent['num_seeders'], - 'num_leechers': torrent['num_leechers'], 'last_tracker_check': 0} - - def get_parameter(parameters, name): """ Return a specific parameter with a name from a HTTP request (or None if that parameter is not available). diff --git a/Tribler/Core/Modules/search_manager.py b/Tribler/Core/Modules/search_manager.py deleted file mode 100644 index c809ef71e10..00000000000 --- a/Tribler/Core/Modules/search_manager.py +++ /dev/null @@ -1,216 +0,0 @@ -from __future__ import absolute_import - -import logging -import os - -from Tribler.Core.Utilities.search_utils import split_into_keywords -from Tribler.Core.simpledefs import (SIGNAL_SEARCH_COMMUNITY, SIGNAL_ALLCHANNEL_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS, - NTFY_CHANNELCAST, SIGNAL_TORRENT, SIGNAL_CHANNEL) -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - - -class SearchManager(TaskManager): - - def __init__(self, session): - super(SearchManager, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - self.session = session - self.dispersy = None - self.channelcast_db = None - - self._current_keywords = None - - def initialize(self): - self.dispersy = self.session.lm.dispersy - self.channelcast_db = self.session.open_dbhandler(NTFY_CHANNELCAST) - - self.session.add_observer(self._on_torrent_search_results, - SIGNAL_SEARCH_COMMUNITY, [SIGNAL_ON_SEARCH_RESULTS]) - self.session.add_observer(self._on_channel_search_results, - SIGNAL_ALLCHANNEL_COMMUNITY, [SIGNAL_ON_SEARCH_RESULTS]) - - def shutdown(self): - self.shutdown_task_manager() - self.channelcast_db = None - self.dispersy = None - self.session = None - - def search_for_torrents(self, keywords): - """ - Searches for torrents using SearchCommunity with the given keywords. - :param keywords: The given keywords. - """ - nr_requests_made = 0 - if self.dispersy is None: - return nr_requests_made - - # TODO remove when we remove Dispersy - from Tribler.community.search.community import SearchCommunity - for community in self.dispersy.get_communities(): - if isinstance(community, SearchCommunity): - self._current_keywords = keywords - nr_requests_made = community.create_search(keywords) - if not nr_requests_made: - self._logger.warn("Could not send search in SearchCommunity, no verified candidates found") - break - - self._current_keywords = keywords - # If popularity community is enabled, send the search request there as well - if self.session.lm.popularity_community: - self.session.lm.popularity_community.send_torrent_search_request(keywords) - - return nr_requests_made - - def _on_torrent_search_results(self, subject, change_type, object_id, search_results): - """ - The callback function handles the search results from SearchCommunity. - :param subject: Must be SIGNAL_SEARCH_COMMUNITY. - :param change_type: Must be SIGNAL_ON_SEARCH_RESULTS. - :param object_id: Must be None. - :param search_results: The result dictionary which has 'keywords', 'results', and 'candidate'. - """ - if self.session is None: - return 0 - - keywords = search_results['keywords'] - results = search_results['results'] - candidate = search_results['candidate'] - - self._logger.debug("Got torrent search results %s, keywords %s, candidate %s", - len(results), keywords, candidate) - - # drop it if these are the results of an old keyword - if keywords != self._current_keywords: - return - - # results is a list of tuples that are: - # (1) infohash, (2) name, (3) length, (4) num_files, (5) category, (6) creation_date, (7) num_seeders - # (8) num_leechers, (9) channel_cid - - remote_torrent_result_list = [] - - # get and cache channels - channel_cid_list = [result[-1] for result in results if result[-1] is not None] - channel_cache_list = self.channelcast_db.getChannelsByCID(channel_cid_list) - channel_cache_dict = {} - for channel in channel_cache_list: - # index 1 is cid - channel_cache_dict[channel[1]] = channel - - # create result dictionaries that are understandable - for result in results: - remote_torrent_result = {'torrent_type': 'remote', # indicates if it is a remote torrent - 'relevance_score': None, - 'torrent_id':-1, - 'infohash': result[0], - 'name': result[1], - 'length': result[2], - 'num_files': result[3], - 'category': result[4][0], - 'creation_date': result[5], - 'num_seeders': result[6], - 'num_leechers': result[7], - 'status': u'good', - 'query_candidates': {candidate}, - 'channel': None} - - channel_cid = result[-1] - if channel_cid is not None and channel_cid in channel_cache_dict: - channel = channel_cache_dict[channel_cid] - channel_result = {'id': channel[0], - 'name': channel[2], - 'description': channel[3], - 'dispersy_cid': channel[1], - 'num_torrents': channel[4], - 'num_favorite': channel[5], - 'num_spam': channel[6], - 'modified': channel[8], - } - remote_torrent_result['channel'] = channel_result - - # guess matches - keyword_set = set(keywords) - swarmname_set = set(split_into_keywords(remote_torrent_result['name'])) - matches = {'fileextensions': set(), - 'swarmname': swarmname_set & keyword_set, # all keywords matching in swarmname - } - matches['filenames'] = keyword_set - matches['swarmname'] # remaining keywords should thus me matching in filenames or fileextensions - - if len(matches['filenames']) == 0: - _, ext = os.path.splitext(result[0]) - ext = ext[1:] - - matches['filenames'] = matches['swarmname'] - matches['filenames'].discard(ext) - - if ext in keyword_set: - matches['fileextensions'].add(ext) - - # Find the lowest term position of the matching keywords - pos_score = None - if matches['swarmname']: - swarmnameTerms = split_into_keywords(remote_torrent_result['name']) - swarmnameMatches = matches['swarmname'] - - for i, term in enumerate(swarmnameTerms): - if term in swarmnameMatches: - pos_score = -i - break - - remote_torrent_result['relevance_score'] = [len(matches['swarmname']), - pos_score, - len(matches['filenames']), - len(matches['fileextensions']), - 0] - - # append the result into the result list - remote_torrent_result_list.append(remote_torrent_result) - - results_data = {'keywords': keywords, - 'result_list': remote_torrent_result_list} - # inform other components about the results - self.session.notifier.notify(SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, None, results_data) - - def search_for_channels(self, keywords): - """ - Searches for channels using AllChannelCommunity with the given keywords. - :param keywords: The given keywords. - """ - if self.dispersy is None: - return - - #TODO remove when we remove Dispersy - from Tribler.community.allchannel.community import AllChannelCommunity - for community in self.dispersy.get_communities(): - if isinstance(community, AllChannelCommunity): - self._current_keywords = keywords - community.create_channelsearch(keywords) - break - - def _on_channel_search_results(self, subject, change_type, object_id, search_results): - """ - The callback function handles the search results from AllChannelCommunity. - :param subject: Must be SIGNAL_ALLCHANNEL_COMMUNITY. - :param change_type: Must be SIGNAL_ON_SEARCH_RESULTS. - :param object_id: Must be None. - :param search_results: The result dictionary which has 'keywords', 'results', and 'candidate'. - """ - if self.session is None: - return - - keywords = search_results['keywords'] - results = search_results['torrents'] - - self._logger.debug("Got channel search results %s. keywords %s", - len(results), keywords) - - if keywords != self._current_keywords: - return - - channel_cids = results.keys() - channel_results = self.channelcast_db.getChannelsByCID(channel_cids) - - results_data = {'keywords': keywords, - 'result_list': channel_results} - # inform other components about the results - self.session.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, None, results_data) diff --git a/Tribler/Core/Modules/tracker_manager.py b/Tribler/Core/Modules/tracker_manager.py index 64966db9c56..04a42f2382b 100644 --- a/Tribler/Core/Modules/tracker_manager.py +++ b/Tribler/Core/Modules/tracker_manager.py @@ -1,6 +1,10 @@ +from __future__ import absolute_import + import logging import time +from pony.orm import count, db_session + from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url MAX_TRACKER_FAILURES = 5 # if a tracker fails this amount of times in a row, its 'is_alive' will be marked as 0 (dead). @@ -13,6 +17,10 @@ def __init__(self, session): self._logger = logging.getLogger(self.__class__.__name__) self._session = session + @property + def tracker_store(self): + return self._session.lm.mds.TrackerState + def get_tracker_info(self, tracker_url): """ Gets the tracker information with the given tracker URL. @@ -20,13 +28,18 @@ def get_tracker_info(self, tracker_url): :return: The tracker info dict if exists, None otherwise. """ sanitized_tracker_url = get_uniformed_tracker_url(tracker_url) if tracker_url != u"DHT" else tracker_url - try: - sql_stmt = u"SELECT tracker_id, tracker, last_check, failures, is_alive FROM TrackerInfo WHERE tracker = ?" - result = self._session.sqlite_db.execute(sql_stmt, (sanitized_tracker_url,)).next() - except StopIteration: - return None - return {u'id': result[0], u'last_check': result[2], u'failures': result[3], u'is_alive': bool(result[4])} + with db_session: + tracker = list(self.tracker_store.select(lambda g: g.url == sanitized_tracker_url)) + if tracker: + return { + u'id': tracker[0].url, + u'last_check': tracker[0].last_check, + u'failures': tracker[0].failures, + u'is_alive': tracker[0].alive + } + else: + return None def add_tracker(self, tracker_url): """ @@ -38,24 +51,18 @@ def add_tracker(self, tracker_url): self._logger.warn(u"skip invalid tracker: %s", repr(tracker_url)) return - sql_stmt = u"SELECT COUNT() FROM TrackerInfo WHERE tracker = ?" - num = self._session.sqlite_db.execute(sql_stmt, (sanitized_tracker_url,)).next()[0] - if num > 0: - self._logger.debug(u"skip existing tracker: %s", repr(tracker_url)) - return - - # add the tracker into dict and database - tracker_info = {u'last_check': 0, - u'failures': 0, - u'is_alive': True} + with db_session: + num = count(g for g in self.tracker_store if g.url == sanitized_tracker_url) + if num > 0: + self._logger.debug(u"skip existing tracker: %s", repr(tracker_url)) + return - # insert into database - sql_stmt = u"""INSERT INTO TrackerInfo(tracker, last_check, failures, is_alive) VALUES(?,?,?,?); - SELECT tracker_id FROM TrackerInfo WHERE tracker = ?; - """ - value_tuple = (sanitized_tracker_url, tracker_info[u'last_check'], tracker_info[u'failures'], - tracker_info[u'is_alive'], sanitized_tracker_url) - self._session.sqlite_db.execute(sql_stmt, value_tuple).next() + # insert into database + self.tracker_store(url=sanitized_tracker_url, + last_check=0, + failures=0, + alive=True, + torrents={}) def remove_tracker(self, tracker_url): """ @@ -65,48 +72,47 @@ def remove_tracker(self, tracker_url): :param tracker_url: The URL of the tracker to be deleted. """ sanitized_tracker_url = get_uniformed_tracker_url(tracker_url) - sql_stmt = u"DELETE FROM TrackerInfo WHERE tracker = ?;" - if sanitized_tracker_url: - self._session.sqlite_db.execute(sql_stmt, (sanitized_tracker_url,)) - else: - self._session.sqlite_db.execute(sql_stmt, (tracker_url,)) + with db_session: + options = self.tracker_store.select(lambda g: g.url in [tracker_url, sanitized_tracker_url]) + for option in options[:]: + option.delete() + + @db_session def update_tracker_info(self, tracker_url, is_successful): """ Updates a tracker information. :param tracker_url: The given tracker_url. :param is_successful: If the check was successful. """ - tracker_info = self.get_tracker_info(tracker_url) - if not tracker_info: + sanitized_tracker_url = get_uniformed_tracker_url(tracker_url) if tracker_url != u"DHT" else tracker_url + tracker = list(self.tracker_store.select(lambda g: g.url == sanitized_tracker_url)) + + if not tracker: self._logger.error("Trying to update the tracker info of an unknown tracker URL") return + tracker = tracker[0] current_time = int(time.time()) - failures = 0 if is_successful else tracker_info[u'failures'] + 1 - is_alive = tracker_info[u'failures'] < MAX_TRACKER_FAILURES + failures = 0 if is_successful else tracker.failures + 1 + is_alive = tracker.alive < MAX_TRACKER_FAILURES # update the dict - tracker_info[u'last_check'] = current_time - tracker_info[u'failures'] = failures - tracker_info[u'is_alive'] = is_alive - - # update the database - sql_stmt = u"UPDATE TrackerInfo SET last_check = ?, failures = ?, is_alive = ? WHERE tracker_id = ?" - value_tuple = (tracker_info[u'last_check'], tracker_info[u'failures'], tracker_info[u'is_alive'], - tracker_info[u'id']) - self._session.sqlite_db.execute(sql_stmt, value_tuple) + tracker.last_check = current_time + tracker.failures = failures + tracker.alive = is_alive + @db_session def get_next_tracker_for_auto_check(self): """ Gets the next tracker for automatic tracker-checking. :return: The next tracker for automatic tracker-checking. """ - try: - sql_stmt = u"SELECT tracker FROM TrackerInfo WHERE tracker != 'no-DHT' AND tracker != 'DHT' AND " \ - u"last_check + ? <= strftime('%s','now') AND is_alive = 1 ORDER BY last_check LIMIT 1;" - result = self._session.sqlite_db.execute(sql_stmt, (TRACKER_RETRY_INTERVAL,)).next() - except StopIteration: - return None + tracker = self.tracker_store.select(lambda g: g.url not in ['no-DHT', 'DHT'] + and g.alive + and g.last_check + TRACKER_RETRY_INTERVAL <= int(time.time()))\ + .order_by(self.tracker_store.last_check).limit(1) - return result[0] + if not tracker: + return None + return tracker[0].url diff --git a/Tribler/Core/CacheDB/Notifier.py b/Tribler/Core/Notifier.py similarity index 100% rename from Tribler/Core/CacheDB/Notifier.py rename to Tribler/Core/Notifier.py diff --git a/Tribler/Core/RemoteTorrentHandler.py b/Tribler/Core/RemoteTorrentHandler.py deleted file mode 100644 index ecafeaea8b5..00000000000 --- a/Tribler/Core/RemoteTorrentHandler.py +++ /dev/null @@ -1,628 +0,0 @@ -""" -Handles the case where the user did a remote query and now selected one of the -returned torrents for download. - -Author(s): Niels Zeilemaker -""" -from __future__ import absolute_import - -import logging -import sys -import urllib -from abc import ABCMeta, abstractmethod -from binascii import hexlify, unhexlify -from collections import deque - -from decorator import decorator - -from twisted.internet import reactor -from twisted.internet.task import LoopingCall - -from Tribler.Core.TFTP.handler import METADATA_PREFIX -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.exceptions import LevelDBKeyDeletionException -from Tribler.Core.simpledefs import INFOHASH_LENGTH, NTFY_TORRENTS -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - -TORRENT_OVERFLOW_CHECKING_INTERVAL = 30 * 60 -LOW_PRIO_COLLECTING = 0 -MAGNET_TIMEOUT = 5.0 -MAX_PRIORITY = 1 - -@decorator -def pass_when_stopped(f, self, *argv, **kwargs): - if self.running: - return f(self, *argv, **kwargs) - - -class RemoteTorrentHandler(TaskManager): - - def __init__(self, session): - super(RemoteTorrentHandler, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - - self.running = False - - self.torrent_callbacks = {} - self.metadata_callbacks = {} - - self.torrent_requesters = {} - self.torrent_message_requesters = {} - self.magnet_requesters = {} - self.metadata_requester = None - - self.num_torrents = 0 - - self.session = session - self.dispersy = None - self.max_num_torrents = 0 - self.tor_col_dir = None - self.torrent_db = None - - def initialize(self): - self.dispersy = self.session.get_dispersy_instance() - self.max_num_torrents = self.session.config.get_torrent_collecting_max_torrents() - - self.torrent_db = None - if self.session.config.get_megacache_enabled(): - self.torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - self.__check_overflow() - - self.running = True - - for priority in (0, 1): - self.magnet_requesters[priority] = MagnetRequester(self.session, self, priority) - self.torrent_requesters[priority] = TftpRequester(u"tftp_torrent_%s" % priority, - self.session, self, priority) - self.torrent_message_requesters[priority] = TorrentMessageRequester(self.session, self, priority) - - self.metadata_requester = TftpRequester(u"tftp_metadata_%s" % 0, self.session, self, 0) - - - def shutdown(self): - self.running = False - for requester in self.torrent_requesters.itervalues(): - requester.stop() - self.shutdown_task_manager() - - def set_max_num_torrents(self, max_num_torrents): - self.max_num_torrents = max_num_torrents - - def __check_overflow(self): - def clean_until_done(num_delete, deletions_per_step): - """ - Delete torrents in steps to avoid too much IO at once. - """ - if num_delete > 0: - to_remove = min(num_delete, deletions_per_step) - num_delete -= to_remove - try: - self.torrent_db.freeSpace(to_remove) - self.register_task(u"remote_torrent clean_until_done", - reactor.callLater(5, clean_until_done, num_delete, deletions_per_step)) - except LevelDBKeyDeletionException: - self._logger.error("Failed to remove collected torrents above limit.") - - def torrent_overflow_check(): - """ - Check if we have reached the collected torrent limit and throttle its collection if so. - """ - self.num_torrents = self.torrent_db.getNumberCollectedTorrents() - self._logger.debug(u"check overflow: current %d max %d", self.num_torrents, self.max_num_torrents) - - if self.num_torrents > self.max_num_torrents: - num_delete = int(self.num_torrents - self.max_num_torrents * 0.95) - deletions_per_step = max(25, num_delete / 180) - clean_until_done(num_delete, deletions_per_step) - self._logger.info(u"** limit space:: %d %d %d", self.num_torrents, self.max_num_torrents, num_delete) - - self.register_task(u"remote_torrent overflow_check", - LoopingCall(torrent_overflow_check)).start(TORRENT_OVERFLOW_CHECKING_INTERVAL, now=True) - - def schedule_task(self, name, task, delay_time=0.0, *args, **kwargs): - self.register_task(name, reactor.callLater(delay_time, task, *args, **kwargs)) - - def download_torrent(self, candidate, infohash, user_callback=None, priority=1, timeout=None): - assert isinstance(infohash, str), u"infohash has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, u"infohash has invalid length: %s" % len(infohash) - - # fix prio levels to 1 and 0 - priority = min(priority, 1) - - # we use DHT if we don't have candidate - if candidate: - self.torrent_requesters[priority].add_request(infohash, candidate, timeout) - else: - self.magnet_requesters[priority].add_request(infohash) - - if user_callback: - callback = lambda ih = infohash: user_callback(ih) - self.torrent_callbacks.setdefault(infohash, set()).add(callback) - - def save_torrent(self, tdef, callback=None): - infohash = tdef.get_infohash() - infohash_str = hexlify(infohash) - - if self.session.lm.torrent_store is None: - self._logger.error("Torrent store is not loaded") - return - - # TODO(emilon): could we check the database instead of the store? - # Checking if a key is present fetches the whole torrent from disk if its - # not on the writeback cache. - if infohash_str not in self.session.lm.torrent_store: - # save torrent to file - try: - bdata = tdef.encode() - - except Exception as e: - self._logger.error(u"failed to encode torrent %s: %s", infohash_str, e) - return - try: - self.session.lm.torrent_store[infohash_str] = bdata - except Exception as e: - self._logger.error(u"failed to store torrent data for %s, exception was: %s", infohash_str, e) - - # add torrent to database - if self.torrent_db.hasTorrent(infohash): - self.torrent_db.updateTorrent(infohash, is_collected=1) - else: - self.torrent_db.addExternalTorrent(tdef, extra_info={u"is_collected": 1, u"status": u"good"}) - - if callback: - # TODO(emilon): should we catch exceptions from the callback? - callback() - - # notify all - self.notify_possible_torrent_infohash(infohash) - - def download_torrentmessage(self, candidate, infohash, user_callback=None, priority=1): - assert isinstance(infohash, str), u"infohash has invalid type: %s" % type(infohash) - assert len(infohash) == INFOHASH_LENGTH, u"infohash has invalid length: %s" % len(infohash) - - if user_callback: - callback = lambda ih = infohash: user_callback(ih) - self.torrent_callbacks.setdefault(infohash, set()).add(callback) - - requester = self.torrent_message_requesters[priority] - - # make request - requester.add_request(infohash, candidate) - self._logger.debug(u"adding torrent messages request: %s %s %s", hexlify(infohash), candidate, priority) - - def has_metadata(self, thumb_hash): - thumb_hash_str = hexlify(thumb_hash) - return thumb_hash_str in self.session.lm.metadata_store - - def get_metadata(self, thumb_hash): - thumb_hash_str = hexlify(thumb_hash) - return self.session.lm.metadata_store[thumb_hash_str] - - def download_metadata(self, candidate, thumb_hash, usercallback=None, timeout=None): - if self.has_metadata(thumb_hash): - return - - if usercallback: - self.metadata_callbacks.setdefault(thumb_hash, set()).add(usercallback) - - self.metadata_requester.add_request(thumb_hash, candidate, timeout, is_metadata=True) - - self._logger.debug(u"added metadata request: %s %s", hexlify(thumb_hash), candidate) - - def save_metadata(self, thumb_hash, data): - # save data to a temporary tarball and extract it to the torrent collecting directory - thumb_hash_str = hexlify(thumb_hash) - if thumb_hash_str not in self.session.lm.metadata_store: - self.session.lm.metadata_store[thumb_hash_str] = data - - # notify about the new metadata - if thumb_hash in self.metadata_callbacks: - for callback in self.metadata_callbacks[thumb_hash]: - reactor.callInThread(callback, hexlify(thumb_hash)) - - del self.metadata_callbacks[thumb_hash] - - def notify_possible_torrent_infohash(self, infohash): - if infohash not in self.torrent_callbacks: - return - - for callback in self.torrent_callbacks[infohash]: - reactor.callInThread(callback, hexlify(infohash)) - - del self.torrent_callbacks[infohash] - - def get_queue_size_stats(self): - def get_queue_size_stats(qname, requesters): - qsize = {} - for requester in requesters.itervalues(): - qsize[requester.priority] = requester.pending_request_queue_size - items = qsize.items() - items.sort() - return {"type": qname, "size_stats": [{"priority": prio, "size": size} for prio, size in items]} - - return [stats_dict for stats_dict in (get_queue_size_stats("TFTP", self.torrent_requesters), - get_queue_size_stats("DHT", self.magnet_requesters), - get_queue_size_stats("Msg", self.torrent_message_requesters))] - - def get_queue_stats(self): - def get_queue_stats(qname, requesters): - pending_requests = success = failed = 0 - for requester in requesters.itervalues(): - pending_requests += requester.pending_request_queue_size - success += requester.requests_succeeded - failed += requester.requests_failed - total_requests = pending_requests + success + failed - - return {"type": qname, "total": total_requests, "success": success, - "pending": pending_requests, "failed": failed} - - return [stats_dict for stats_dict in [get_queue_stats("TFTP", self.torrent_requesters), - get_queue_stats("DHT", self.magnet_requesters), - get_queue_stats("Msg", self.torrent_message_requesters)]] - - def get_bandwidth_stats(self): - def get_bandwidth_stats(qname, requesters): - bw = 0 - for requester in requesters.itervalues(): - bw += requester.total_bandwidth - return {"type": qname, "bandwidth": bw} - return [stats_dict for stats_dict in [get_bandwidth_stats("TQueue", self.torrent_requesters), - get_bandwidth_stats("DQueue", self.magnet_requesters)]] - - -class Requester(object): - __metaclass__ = ABCMeta - - REQUEST_INTERVAL = 0.5 - - def __init__(self, name, session, remote_torrent_handler, priority): - self._logger = logging.getLogger(self.__class__.__name__) - self._name = name - self._session = session - self._remote_torrent_handler = remote_torrent_handler - self._priority = priority - - self._pending_request_queue = deque() - - self._requests_succeeded = 0 - self._requests_failed = 0 - self._total_bandwidth = 0 - - self.running = True - - def stop(self): - self._remote_torrent_handler.cancel_pending_task(self._name) - self.running = False - - @property - def priority(self): - return self._priority - - @property - def pending_request_queue_size(self): - return len(self._pending_request_queue) - - @property - def requests_succeeded(self): - return self._requests_succeeded - - @property - def requests_failed(self): - return self._requests_failed - - @property - def total_bandwidth(self): - return self._total_bandwidth - - @pass_when_stopped - def schedule_task(self, task, delay_time=0.0, *args, **kwargs): - """ - Uses RemoteTorrentHandler to schedule a task. - """ - self._remote_torrent_handler.schedule_task(self._name, task, delay_time=delay_time, *args, **kwargs) - - @pass_when_stopped - def _start_pending_requests(self): - """ - Starts pending requests. - """ - if self._remote_torrent_handler.is_pending_task_active(self._name): - return - if self._pending_request_queue: - self.schedule_task(self._do_request, - delay_time=Requester.REQUEST_INTERVAL * (MAX_PRIORITY - self._priority)) - - @abstractmethod - def add_request(self, key, candidate, timeout=None): - """ - Adds a new request. - """ - pass - - @abstractmethod - def _do_request(self): - """ - Starts processing pending requests. - """ - pass - - -class TorrentMessageRequester(Requester): - - def __init__(self, session, remote_torrent_handler, priority): - super(TorrentMessageRequester, self).__init__(u"torrent_message_requester", - session, remote_torrent_handler, priority) - if sys.platform == "darwin": - # Mac has just 256 fds per process, be less aggressive - self.REQUEST_INTERVAL = 1.0 - - self._source_dict = {} - self._search_community = None - - @pass_when_stopped - def add_request(self, infohash, candidate, timeout=None): - addr = candidate.sock_addr - queue_was_empty = len(self._pending_request_queue) == 0 - - if infohash in self._source_dict and candidate in self._source_dict[infohash]: - self._logger.debug(u"already has request %s from %s:%s, skip", hexlify(infohash), addr[0], addr[1]) - - if infohash not in self._pending_request_queue: - self._pending_request_queue.append(infohash) - self._source_dict[infohash] = [] - if candidate in self._source_dict[infohash]: - self._logger.warn(u"ignore duplicate torrent message request %s from %s:%s", - hexlify(infohash), addr[0], addr[1]) - return - - self._source_dict[infohash].append(candidate) - self._logger.debug(u"added request %s from %s:%s", hexlify(infohash), addr[0], addr[1]) - - # start scheduling tasks if the queue was empty, which means there was no task running previously - if queue_was_empty: - self._start_pending_requests() - - @pass_when_stopped - def _do_request(self): - # find search community - if not self._search_community: - for community in self._session.lm.dispersy.get_communities(): - from Tribler.community.search.community import SearchCommunity - if isinstance(community, SearchCommunity): - self._search_community = community - break - if not self._search_community: - self._logger.error(u"no SearchCommunity found.") - return - - # requesting messages - while self._pending_request_queue: - infohash = self._pending_request_queue.popleft() - - for candidate in self._source_dict[infohash]: - self._logger.debug(u"requesting torrent message %s from %s:%s", - hexlify(infohash), candidate.sock_addr[0], candidate.sock_addr[1]) - self._search_community.create_torrent_request(infohash, candidate) - - del self._source_dict[infohash] - - -class MagnetRequester(Requester): - - MAX_CONCURRENT = 1 - TIMEOUT = 30.0 - - def __init__(self, session, remote_torrent_handler, priority): - super(MagnetRequester, self).__init__(u"magnet_requester", session, remote_torrent_handler, priority) - if sys.platform == "darwin": - # Mac has just 256 fds per process, be less aggressive - self.REQUEST_INTERVAL = 15.0 - - if priority <= 1 and not sys.platform == "darwin": - self.MAX_CONCURRENT = 3 - - self._torrent_db_handler = session.open_dbhandler(NTFY_TORRENTS) - - self._running_requests = [] - - @pass_when_stopped - def add_request(self, infohash, candidate=None, timeout=None): - queue_was_empty = len(self._pending_request_queue) == 0 - if infohash not in self._pending_request_queue and infohash not in self._running_requests: - self._pending_request_queue.append(infohash) - - # start scheduling tasks if the queue was empty, which means there was no task running previously - if queue_was_empty: - self._start_pending_requests() - - @pass_when_stopped - def _do_request(self): - while self._pending_request_queue and self.running: - if len(self._running_requests) >= self.MAX_CONCURRENT: - self._logger.debug(u"max concurrency %s reached, request later", self.MAX_CONCURRENT) - return - - infohash = self._pending_request_queue.popleft() - infohash_str = hexlify(infohash) - - # try magnet link - magnetlink = "magnet:?xt=urn:btih:" + infohash_str - - # see if we know any trackers for this magnet - trackers = self._torrent_db_handler.getTrackerListByInfohash(infohash) - for tracker in trackers: - if tracker not in (u"no-DHT", u"DHT"): - magnetlink += "&tr=" + urllib.quote_plus(tracker) - - self._logger.debug(u"requesting %s priority %s through magnet link %s", - infohash_str, self._priority, magnetlink) - - self._session.lm.ltmgr.get_metainfo(magnetlink, self._success_callback, - timeout=self.TIMEOUT, timeout_callback=self._failure_callback) - self._running_requests.append(infohash) - - def _success_callback(self, meta_info): - """ - The callback that will be called by LibtorrentMgr when a download was successful. - """ - tdef = TorrentDef.load_from_dict(meta_info) - assert tdef.get_infohash() in self._running_requests - - infohash = tdef.get_infohash() - self._logger.debug(u"received torrent %s through magnet", hexlify(infohash)) - - self._remote_torrent_handler.save_torrent(tdef) - self._running_requests.remove(infohash) - - self._requests_succeeded += 1 - self._total_bandwidth += tdef.get_torrent_size() - - self._start_pending_requests() - - def _failure_callback(self, infohash): - """ - The callback that will be called by LibtorrentMgr when a download failed. - """ - if infohash not in self._running_requests: - self._logger.debug(u"++ failed INFOHASH: %s", hexlify(infohash)) - for ih in self._running_requests: - self._logger.debug(u"++ INFOHASH in running_requests: %s", hexlify(ih)) - - self._logger.debug(u"failed to retrieve torrent %s through magnet", hexlify(infohash)) - self._running_requests.remove(infohash) - - self._requests_failed += 1 - - self._start_pending_requests() - - -class TftpRequester(Requester): - - def __init__(self, name, session, remote_torrent_handler, priority): - super(TftpRequester, self).__init__(name, session, remote_torrent_handler, priority) - - self.REQUEST_INTERVAL = 5.0 - - self._active_request_list = [] - self._untried_sources = {} - self._tried_sources = {} - - @pass_when_stopped - def add_request(self, key, candidate, timeout=None, is_metadata=False): - ip, port = candidate.sock_addr - # no binary for keys - if is_metadata: - key = "%s%s" % (METADATA_PREFIX, hexlify(key)) - key_str = key - else: - key = hexlify(key) - key_str = hexlify(key) - - if key in self._pending_request_queue or key in self._active_request_list: - # append to the active one - if candidate in self._untried_sources[key] or candidate in self._tried_sources[key]: - self._logger.debug(u"already has request %s from %s:%s, skip", key_str, ip, port) - return - - self._untried_sources[key].append(candidate) - self._logger.debug(u"appending to existing request: %s from %s:%s", key_str, ip, port) - - else: - # new request - self._logger.debug(u"adding new request: %s from %s:%s", key_str, ip, port) - self._pending_request_queue.append(key) - self._untried_sources[key] = deque([candidate]) - self._tried_sources[key] = deque() - - # start pending tasks if there is no task running - if not self._active_request_list: - self._start_pending_requests() - - @pass_when_stopped - def _do_request(self): - assert not self._active_request_list, "active_request_list is not empty = %s" % repr(self._active_request_list) - - # starts to download a torrent - key = self._pending_request_queue.popleft() - - candidate = self._untried_sources[key].popleft() - self._tried_sources[key].append(candidate) - - ip, port = candidate.sock_addr - - if key.startswith(METADATA_PREFIX): - # metadata requests has a METADATA_PREFIX prefix - thumb_hash = unhexlify(key[len(METADATA_PREFIX):]) - file_name = key - extra_info = {u'key': key, u'thumb_hash': thumb_hash} - else: - # key is the hexlified info hash - info_hash = unhexlify(key) - file_name = hexlify(info_hash) + u'.torrent' - extra_info = {u'key': key, u'info_hash': info_hash} - - self._logger.debug(u"start TFTP download for %s from %s:%s", file_name, ip, port) - - # do not download if TFTP has been shutdown - if self._session.lm.tftp_handler is None: - return - self._session.lm.tftp_handler.download_file(file_name, ip, port, extra_info=extra_info, - success_callback=self._on_download_successful, - failure_callback=self._on_download_failed) - self._active_request_list.append(key) - - def _clear_active_request(self, key): - del self._untried_sources[key] - del self._tried_sources[key] - self._active_request_list.remove(key) - - def _on_download_successful(self, address, file_name, file_data, extra_info): - self._logger.debug(u"successfully downloaded %s from %s:%s", file_name, address[0], address[1]) - - key = extra_info[u'key'] - info_hash = extra_info.get(u"info_hash") - thumb_hash = extra_info.get(u"thumb_hash") - - assert key in self._active_request_list, u"key = %s, active_request_list = %s" % (repr(key), - self._active_request_list) - - self._requests_succeeded += 1 - self._total_bandwidth += len(file_data) - - # save data - try: - if info_hash is not None: - # save torrent - tdef = TorrentDef.load_from_memory(file_data) - self._remote_torrent_handler.save_torrent(tdef) - elif thumb_hash is not None: - # save metadata - self._remote_torrent_handler.save_metadata(thumb_hash, file_data) - except ValueError: - self._logger.warning("Remote peer sent us invalid (torrent) content over TFTP socket, ignoring it.") - finally: - # start the next request - self._clear_active_request(key) - self._start_pending_requests() - - def _on_download_failed(self, address, file_name, error_msg, extra_info): - self._logger.debug(u"failed to download %s from %s:%s: %s", file_name, address[0], address[1], error_msg) - - key = extra_info[u'key'] - assert key in self._active_request_list, u"key = %s, active_request_list = %s" % (repr(key), - self._active_request_list) - - self._requests_failed += 1 - - if self._untried_sources[key]: - # try to download this data from another candidate - self._logger.debug(u"scheduling next try for %s", repr(key)) - - self._pending_request_queue.appendleft(key) - self._active_request_list.remove(key) - self.schedule_task(self._do_request) - - else: - # no more available candidates, download the next requested infohash - self._clear_active_request(key) - self._start_pending_requests() diff --git a/Tribler/Core/Session.py b/Tribler/Core/Session.py index 020f0500fb6..beec42bb842 100644 --- a/Tribler/Core/Session.py +++ b/Tribler/Core/Session.py @@ -3,12 +3,13 @@ Author(s): Arno Bakker """ +from __future__ import absolute_import + import errno import logging import os import sys -import time -from binascii import hexlify +from threading import RLock from twisted.internet import threads from twisted.internet.defer import fail, inlineCallbacks @@ -19,21 +20,20 @@ import Tribler.Core.permid as permid_module from Tribler.Core.APIImplementation.LaunchManyCore import TriblerLaunchMany from Tribler.Core.CacheDB.Notifier import Notifier -from Tribler.Core.CacheDB.sqlitecachedb import DB_DIR_NAME, DB_FILE_RELATIVE_PATH, SQLiteCacheDB from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Modules.restapi.rest_manager import RESTManager +from Tribler.Core.Notifier import Notifier from Tribler.Core.Upgrade.upgrade import TriblerUpgrader from Tribler.Core.Utilities import torrent_utils from Tribler.Core.Utilities.crypto_patcher import patch_crypto_be_discovery from Tribler.Core.exceptions import DuplicateTorrentFileError, NotYetImplementedException, \ OperationNotEnabledByConfigurationException -from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, NTFY_DELETE, NTFY_INSERT, NTFY_MYPREFERENCES, NTFY_PEERS, - STATEDIR_CHANNELS_DIR, NTFY_TORRENTS, NTFY_TRIBLER, NTFY_UPDATE, NTFY_VOTECAST, - STATEDIR_DLPSTATE_DIR, - STATEDIR_WALLET_DIR, STATE_LOAD_CHECKPOINTS, STATE_OPEN_DB, STATE_READABLE_STARTED, +from Tribler.Core.simpledefs import (NTFY_CHANNELCAST, NTFY_DELETE, NTFY_INSERT, STATEDIR_CHANNELS_DIR, NTFY_TRIBLER, + NTFY_UPDATE, STATEDIR_DLPSTATE_DIR, + STATEDIR_WALLET_DIR, STATE_LOAD_CHECKPOINTS, STATE_READABLE_STARTED, STATE_SHUTDOWN, STATE_START_API, STATE_UPGRADING_READABLE) +from Tribler.Core.simpledefs import (STATEDIR_DB_DIR) from Tribler.Core.statistics import TriblerStatistics -from Tribler.pyipv8.ipv8.util import cast_to_long try: long # pylint: disable=long-builtin @@ -71,7 +71,7 @@ def __init__(self, config=None, autoload_discovery=True): self._logger = logging.getLogger(self.__class__.__name__) - self.session_lock = NoDispersyRLock() + self.session_lock = RLock() self.config = config or TriblerConfig() self._logger.info("Session is using state directory: %s", self.config.get_state_dir()) @@ -79,9 +79,6 @@ def __init__(self, config=None, autoload_discovery=True): self.get_ports_in_config() self.create_state_directory_structure() - if not self.config.get_megacache_enabled(): - self.config.set_torrent_checking_enabled(False) - self.selected_ports = self.config.selected_ports self.init_keypair() @@ -89,9 +86,7 @@ def __init__(self, config=None, autoload_discovery=True): self.lm = TriblerLaunchMany() self.notifier = Notifier() - self.sqlite_db = None self.upgrader_enabled = True - self.dispersy_member = None self.readable_status = '' # Human-readable string to indicate the status during startup/shutdown of Tribler self.autoload_discovery = autoload_discovery @@ -107,9 +102,7 @@ def create_in_state_dir(path): create_dir(os.path.join(self.config.get_state_dir(), path)) create_dir(self.config.get_state_dir()) - create_dir(self.config.get_torrent_store_dir()) - create_dir(self.config.get_metadata_store_dir()) - create_in_state_dir(DB_DIR_NAME) + create_in_state_dir(STATEDIR_DB_DIR) create_in_state_dir(STATEDIR_DLPSTATE_DIR) create_in_state_dir(STATEDIR_WALLET_DIR) create_in_state_dir(STATEDIR_CHANNELS_DIR) @@ -117,7 +110,6 @@ def create_in_state_dir(path): def get_ports_in_config(self): """Claim all required random ports.""" self.config.get_libtorrent_port() - self.config.get_dispersy_port() self.config.get_mainline_dht_port() self.config.get_video_server_port() @@ -128,22 +120,6 @@ def init_keypair(self): """ Set parameters that depend on state_dir. """ - permid_module.init() - # Set params that depend on state_dir - # - # 1. keypair - # - pair_filename = self.config.get_permid_keypair_filename() - if os.path.exists(pair_filename): - self.keypair = permid_module.read_keypair(pair_filename) - else: - self.keypair = permid_module.generate_keypair() - - # Save keypair - public_key_filename = os.path.join(self.config.get_state_dir(), 'ecpub.pem') - permid_module.save_keypair(self.keypair, pair_filename) - permid_module.save_pub_key(self.keypair, public_key_filename) - trustchain_pairfilename = self.config.get_trustchain_keypair_filename() if os.path.exists(trustchain_pairfilename): self.trustchain_keypair = permid_module.read_keypair_trustchain(trustchain_pairfilename) @@ -336,8 +312,6 @@ def remove_download_by_id(self, infohash, remove_content=False, remove_state=Tru if download.get_def().get_infohash() == infohash: return self.remove_download(download, remove_content, remove_state) - self.lm.remove_id(infohash) - def set_download_states_callback(self, user_callback, interval=1.0): """ See Download.set_state_callback. Calls user_callback with a list of @@ -355,18 +329,6 @@ def set_download_states_callback(self, user_callback, interval=1.0): """ self.lm.set_download_states_callback(user_callback, interval) - # - # Config parameters that only exist at runtime - # - def get_permid(self): - """ - Returns the PermID of the Session, as determined by the - TriblerConfig.set_permid() parameter. A PermID is a public key. - - :return: the PermID encoded in a string in DER format - """ - return str(self.keypair.pub().get_der()) - # # Notification of events in the Session # @@ -401,49 +363,10 @@ def remove_observer(self, function): """ self.notifier.remove_observer(function) - def open_dbhandler(self, subject): - """ - Opens a connection to the specified database. Only the thread calling this method may - use this connection. The connection must be closed with close_dbhandler() when this - thread exits. This function is called by any thread. - - ;param subject: the database to open. Must be one of the subjects specified here. - :return: a reference to a DBHandler class for the specified subject or - None when the Session was not started with megacache enabled. - """ - if not self.config.get_megacache_enabled(): - raise OperationNotEnabledByConfigurationException() - - if subject == NTFY_PEERS: - return self.lm.peer_db - elif subject == NTFY_TORRENTS: - return self.lm.torrent_db - elif subject == NTFY_MYPREFERENCES: - return self.lm.mypref_db - elif subject == NTFY_VOTECAST: - return self.lm.votecast_db - elif subject == NTFY_CHANNELCAST: - return self.lm.channelcast_db - else: - raise ValueError(u"Cannot open DB subject: %s" % subject) - - @staticmethod - def close_dbhandler(database_handler): - """Closes the given database connection.""" - database_handler.close() - def get_tribler_statistics(self): """Return a dictionary with general Tribler statistics.""" return TriblerStatistics(self).get_tribler_statistics() - def get_dispersy_statistics(self): - """Return a dictionary with general Dispersy statistics.""" - return TriblerStatistics(self).get_dispersy_statistics() - - def get_dispersy_community_statistics(self): - """Return a dictionary with Dispersy communities statistics.""" - return TriblerStatistics(self).get_dispersy_community_statistics() - def get_ipv8_statistics(self): """Return a dictionary with IPv8 statistics.""" return TriblerStatistics(self).get_ipv8_statistics() @@ -471,15 +394,6 @@ def checkpoint(self): """ self.lm.checkpoint_downloads() - def start_database(self): - """ - Start the SQLite database. - """ - db_path = os.path.join(self.config.get_state_dir(), DB_FILE_RELATIVE_PATH) - - self.sqlite_db = SQLiteCacheDB(db_path) - self.readable_status = STATE_OPEN_DB - def start(self): """ Start a Tribler session by initializing the LaunchManyCore class, opening the database and running the upgrader. @@ -491,10 +405,8 @@ def start(self): self.readable_status = STATE_START_API self.lm.api_manager.start() - self.start_database() - if self.upgrader_enabled: - upgrader = TriblerUpgrader(self, self.sqlite_db) + upgrader = TriblerUpgrader(self) self.readable_status = STATE_UPGRADING_READABLE upgrader.run() @@ -538,11 +450,6 @@ def on_early_shutdown_complete(_): self.notify_shutdown_state("Shutting down Metadata Store...") self.lm.mds.shutdown() - if self.sqlite_db: - self.notify_shutdown_state("Shutting down SQLite Database...") - self.sqlite_db.close() - self.sqlite_db = None - # We close the API manager as late as possible during shutdown. if self.lm.api_manager is not None: self.notify_shutdown_state("Shutting down API Manager...") @@ -572,69 +479,6 @@ def get_downloads_pstate_dir(self): """ return os.path.join(self.config.get_state_dir(), STATEDIR_DLPSTATE_DIR) - def download_torrentfile(self, infohash=None, user_callback=None, priority=0): - """ - Try to download the torrent file without a known source. A possible source could be the DHT. - If the torrent is received successfully, the user_callback method is called with the infohash as first - and the contents of the torrent file (bencoded dict) as second parameter. If the torrent could not - be obtained, the callback is not called. The torrent will have been added to the TorrentDBHandler (if enabled) - at the time of the call. - - :param infohash: the infohash of the torrent - :param user_callback: a function adhering to the above spec - :param priority: the priority of this download - """ - if not self.lm.rtorrent_handler: - raise OperationNotEnabledByConfigurationException() - - self.lm.rtorrent_handler.download_torrent(None, infohash, user_callback=user_callback, priority=priority) - - def download_torrentfile_from_peer(self, candidate, infohash=None, user_callback=None, priority=0): - """ - Ask the designated peer to send us the torrent file for the torrent - identified by the passed infohash. If the torrent is successfully - received, the user_callback method is called with the infohash as first - and the contents of the torrent file (bencoded dict) as second parameter. - If the torrent could not be obtained, the callback is not called. - The torrent will have been added to the TorrentDBHandler (if enabled) - at the time of the call. - - :param candidate: the designated peer - :param infohash: the infohash of the torrent - :param user_callback: a function adhering to the above spec - :param priority: priority of this request - """ - if not self.lm.rtorrent_handler: - raise OperationNotEnabledByConfigurationException() - - self.lm.rtorrent_handler.download_torrent(candidate, infohash, user_callback=user_callback, priority=priority) - - def download_torrentmessage_from_peer(self, candidate, infohash, user_callback, priority=0): - """ - Ask the designated peer to send us the torrent message for the torrent - identified by the passed infohash. If the torrent message is successfully - received, the user_callback method is called with the infohash as first - and the contents of the torrent file (bencoded dict) as second parameter. - If the torrent could not be obtained, the callback is not called. - The torrent will have been added to the TorrentDBHandler (if enabled) - at the time of the call. - - :param candidate: the designated peer - :param infohash: the infohash of the torrent - :param user_callback: a function adhering to the above spec - :param priority: priority of this request - """ - if not self.lm.rtorrent_handler: - raise OperationNotEnabledByConfigurationException() - - self.lm.rtorrent_handler.download_torrentmessage(candidate, infohash, user_callback, priority) - - def get_dispersy_instance(self): - if not self.config.get_dispersy_enabled(): - raise OperationNotEnabledByConfigurationException() - - return self.lm.dispersy - def get_ipv8_instance(self): if not self.config.get_ipv8_enabled(): raise OperationNotEnabledByConfigurationException() @@ -663,71 +507,6 @@ def update_trackers(self, infohash, trackers): """ return self.lm.update_trackers(infohash, trackers) - def has_collected_torrent(self, infohash): - """ - Checks if the given torrent infohash exists in the torrent_store database. - - :param infohash: The given infohash binary - :return: True or False indicating if we have the torrent - """ - if not self.config.get_torrent_store_enabled(): - raise OperationNotEnabledByConfigurationException("torrent_store is not enabled") - return hexlify(infohash) in self.lm.torrent_store - - def get_collected_torrent(self, infohash): - """ - Gets the given torrent from the torrent_store database. - - :param infohash: the given infohash binary - :return: the torrent data if exists, None otherwise - """ - if not self.config.get_torrent_store_enabled(): - raise OperationNotEnabledByConfigurationException("torrent_store is not enabled") - return self.lm.torrent_store.get(hexlify(infohash)) - - def save_collected_torrent(self, infohash, data): - """ - Saves the given torrent into the torrent_store database. - - :param infohash: the given infohash binary - :param data: the torrent file data - """ - if not self.config.get_torrent_store_enabled(): - raise OperationNotEnabledByConfigurationException("torrent_store is not enabled") - self.lm.torrent_store.put(hexlify(infohash), data) - - def delete_collected_torrent(self, infohash): - """ - Deletes the given torrent from the torrent_store database. - - :param infohash: the given infohash binary - """ - if not self.config.get_torrent_store_enabled(): - raise OperationNotEnabledByConfigurationException("torrent_store is not enabled") - - del self.lm.torrent_store[hexlify(infohash)] - - def search_remote_torrents(self, keywords): - """ - Searches for remote torrents through SearchCommunity with the given keywords. - - :param keywords: the given keywords - :return: the number of requests made - """ - if not self.config.get_torrent_search_enabled(): - raise OperationNotEnabledByConfigurationException("torrent_search is not enabled") - return self.lm.search_manager.search_for_torrents(keywords) - - def search_remote_channels(self, keywords): - """ - Searches for remote channels through AllChannelCommunity with the given keywords. - - :param keywords: the given keywords - """ - if not self.config.get_channel_search_enabled(): - raise OperationNotEnabledByConfigurationException("channel_search is not enabled") - self.lm.search_manager.search_for_channels(keywords) - @staticmethod def create_torrent_file(file_path_list, params=None): """ @@ -776,7 +555,7 @@ def add_torrent_def_to_channel(self, channel_id, torrent_def, extra_info=None, f community._disp_create_torrent( torrent_def.infohash, - cast_to_long(time.time()), + long(time.time()), torrent_def.get_name_as_unicode(), tuple(torrent_def.get_files_with_length()), torrent_def.get_trackers_as_single_tuple(), @@ -800,17 +579,6 @@ def check_torrent_health(self, infohash, timeout=20, scrape_now=False): return self.lm.torrent_checker.add_gui_request(infohash, timeout=timeout, scrape_now=scrape_now) return fail(Failure(RuntimeError("Torrent checker not available"))) - def get_thumbnail_data(self, thumb_hash): - """ - Gets the thumbnail data. - - :param thumb_hash: the thumbnail SHA1 hash - :return: the thumbnail data - """ - if not self.lm.metadata_store: - raise OperationNotEnabledByConfigurationException("libtorrent is not enabled") - return self.lm.rtorrent_handler.get_metadata(thumb_hash) - def notify_shutdown_state(self, state): self._logger.info("Tribler shutdown state notification:%s", state) self.notifier.notify(NTFY_TRIBLER, STATE_SHUTDOWN, None, state) diff --git a/Tribler/Core/TFTP/__init__.py b/Tribler/Core/TFTP/__init__.py deleted file mode 100644 index 035a3ad2855..00000000000 --- a/Tribler/Core/TFTP/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Contains the the TFTP handler that should be registered at the thread pool to handle TFTP packets -""" diff --git a/Tribler/Core/TFTP/exception.py b/Tribler/Core/TFTP/exception.py deleted file mode 100644 index 2e7fdeb2256..00000000000 --- a/Tribler/Core/TFTP/exception.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -All exceptions used in the TFTP package. -""" - - -class InvalidPacketException(Exception): - """Indicates an invalid packet.""" - pass - - -class InvalidStringException(Exception): - """Indicates an invalid zero-terminated string.""" - pass - - -class FileNotFound(OSError): - """Indicates that a file is not found.""" - pass diff --git a/Tribler/Core/TFTP/handler.py b/Tribler/Core/TFTP/handler.py deleted file mode 100644 index 1fbb4ec2c5a..00000000000 --- a/Tribler/Core/TFTP/handler.py +++ /dev/null @@ -1,568 +0,0 @@ -import logging -from base64 import b64encode -from binascii import hexlify -from hashlib import sha1 -from random import randint -from socket import inet_aton -from struct import unpack -from time import time - -from twisted.internet import reactor -from twisted.internet.task import LoopingCall - -from Tribler.dispersy.candidate import Candidate -from Tribler.dispersy.util import (call_on_reactor_thread, attach_runtime_statistics, is_valid_address) -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - -from .exception import InvalidPacketException, FileNotFound -from .packet import (encode_packet, decode_packet, OPCODE_RRQ, OPCODE_WRQ, OPCODE_ACK, OPCODE_DATA, OPCODE_OACK, - OPCODE_ERROR, ERROR_DICT) -from .session import Session, DEFAULT_BLOCK_SIZE, DEFAULT_TIMEOUT - -MAX_INT16 = 2 ** 16 - 1 - -SEPARATOR = ":" -METADATA_PREFIX = "metadata" + SEPARATOR - -DEFAULT_RETIES = 5 - - -class TftpHandler(TaskManager): - - """ - This is the TFTP handler that should be registered at the thread pool to handle TFTP packets. - """ - - def __init__(self, session, endpoint, prefix, block_size=DEFAULT_BLOCK_SIZE, timeout=DEFAULT_TIMEOUT, - max_retries=DEFAULT_RETIES): - """ The constructor. - :param session: The tribler session. - :param endpoint: The endpoint to use. - :param prefix: The prefix to use. - :param block_size: Transmission block size. - :param timeout: Transmission timeout. - :param max_retries: Transmission maximum retries. - """ - super(TftpHandler, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) - - self.session = session - - self._endpoint = endpoint - self._prefix = prefix - - self._block_size = block_size - self._timeout = timeout - self._max_retries = max_retries - - self._timeout_check_interval = 0.5 - - self._session_id_dict = {} - self._session_dict = {} - - self._callback_scheduled = False - self._callbacks = [] - - self._is_running = False - - def initialize(self): - """ Initializes the TFTP service. We create a UDP socket and a server session. - """ - self._endpoint.listen_to(self._prefix, self.data_came_in) - # start a looping call that checks timeout - self.register_task(u"tftp timeout check", - LoopingCall(self._task_check_timeout)).start(self._timeout_check_interval, now=True) - self._is_running = True - - def shutdown(self): - """ Shuts down the TFTP service. - """ - self.shutdown_task_manager() - if self._endpoint: - self._endpoint.stop_listen_to(self._prefix) - self._endpoint = None - - self._session_id_dict = None - self._session_dict = None - - self._is_running = False - - @call_on_reactor_thread - def download_file(self, file_name, ip, port, extra_info=None, success_callback=None, failure_callback=None): - """ Downloads a file from a remote host. - :param file_name: The file name of the file to be downloaded. - :param ip: The IP of the remote host. - :param port: The port of the remote host. - :param success_callback: The success callback. - :param failure_callback: The failure callback. - """ - # generate a unique session id - # if the target address is higher than ours, we use even number. Otherwise, we use odd number. - if not self._is_running: - return - - target_ip = unpack('!L', inet_aton(ip))[0] - target_port = port - self_ip, self_port = self.session.lm.dispersy.wan_address - self_ip = unpack('!L', inet_aton(self_ip))[0] - if target_ip > self_ip: - generate_session = lambda: randint(0, MAX_INT16) & 0xfff0 - elif target_ip < self_ip: - generate_session = lambda: randint(0, MAX_INT16) | 1 - else: - if target_port > self_port: - generate_session = lambda: randint(0, MAX_INT16) & 0xfff0 - elif target_port < self_port: - generate_session = lambda: randint(0, MAX_INT16) | 1 - else: - self._logger.critical(u"communicating to myself %s:%s", ip, port) - generate_session = lambda: randint(0, MAX_INT16) - - session_id = generate_session() - while (ip, port, session_id) in self._session_dict: - session_id = generate_session() - - # create session - assert session_id is not None, u"session_id = %s" % session_id - self._logger.debug(u"start downloading %s from %s:%s, sid = %s", file_name, ip, port, session_id) - session = Session(True, session_id, (ip, port), OPCODE_RRQ, file_name, '', None, None, - extra_info=extra_info, block_size=self._block_size, timeout=self._timeout, - success_callback=success_callback, failure_callback=failure_callback) - - self._add_new_session(session) - self._send_request_packet(session) - - self._logger.info(u"%s started", session) - - @attach_runtime_statistics(u"{0.__class__.__name__}.{function_name}") - def _task_check_timeout(self): - """ A scheduled task that checks for timeout. - """ - if not self._is_running: - return - - need_session_cleanup = False - for key, session in self._session_dict.items(): - if self._check_session_timeout(session): - need_session_cleanup = True - - # fail as timeout - self._logger.info(u"%s timed out", session) - if session.failure_callback: - callback = lambda cb = session.failure_callback, addr = session.address, fn = session.file_name,\ - msg = "timeout", ei = session.extra_info: cb(addr, fn, msg, ei) - self._callbacks.append(callback) - - self._cleanup_session(key) - - if need_session_cleanup: - self._schedule_callback_processing() - - def _check_session_timeout(self, session): - """ - Checks if a session has timed out and tries to retransmit packet if allowed. - :param session: The given session. - :return: True or False indicating if the session has failed. - """ - has_failed = False - timeout = session.timeout * (2**session.retries) - if session.last_contact_time + timeout < time(): - # we do NOT resend packets that are not data-related - if session.retries < self._max_retries and session.last_sent_packet['opcode'] in (OPCODE_ACK, OPCODE_DATA): - self._send_packet(session, session.last_sent_packet) - session.retries += 1 - else: - has_failed = True - return has_failed - - def _schedule_callback_processing(self): - """ - Schedules a task to process callbacks. - """ - if not self._callback_scheduled: - self.register_task(u"tftp_process_callback", reactor.callLater(0, self._process_callbacks)) - self._callback_scheduled = True - - @attach_runtime_statistics(u"{0.__class__.__name__}.{function_name}") - def _process_callbacks(self): - """ - Process the callbacks - """ - for callback in self._callbacks: - callback() - self._callbacks = [] - self._callback_scheduled = False - - def _add_new_session(self, session): - self._session_id_dict[session.session_id] = 1 + self._session_id_dict.get(session.session_id, 0) - self._session_dict[(session.address[0], session.address[1], session.session_id)] = session - - def _cleanup_session(self, key): - session_id = key[2] - self._session_id_dict[session_id] -= 1 - if self._session_id_dict[session_id] == 0: - del self._session_id_dict[session_id] - del self._session_dict[key] - - @attach_runtime_statistics(u"{0.__class__.__name__}.{function_name}") - @call_on_reactor_thread - def data_came_in(self, addr, data): - """ The callback function that the thread pool will call when there is incoming data. - :param addr: The (IP, port) address tuple of the sender. - :param data: The data received. - """ - if not self._is_running or not is_valid_address(addr): - return - - ip, port = addr - - # decode the packet - try: - packet = decode_packet(data) - except InvalidPacketException as e: - self._logger.error(u"Invalid packet from [%s:%s], packet=[%s], error=%s", ip, port, hexlify(data), e) - return - - if packet['opcode'] == OPCODE_WRQ: - self._logger.error(u"WRQ is not supported from [%s:%s], packet=[%s]", ip, port, repr(packet)) - return - - self._logger.debug(u"GOT packet opcode[%s] from %s:%s", packet['opcode'], ip, port) - # a new request - if packet['opcode'] == OPCODE_RRQ: - self._logger.debug(u"start handling new request: %s", packet) - self._handle_new_request(ip, port, packet) - return - - if (ip, port, packet['session_id']) not in self._session_dict: - self._logger.warn(u"got non-existing session from %s:%s, id = %s", ip, port, packet['session_id']) - return - - # handle the response - session = self._session_dict[(ip, port, packet['session_id'])] - self._process_packet(session, packet) - - if not session.is_done and not session.is_failed: - return - - self._cleanup_session((ip, port, packet['session_id'])) - - # schedule callback - if session.is_failed: - self._logger.info(u"%s failed", session) - if session.failure_callback: - callback = lambda cb = session.failure_callback, a = session.address, fn = session.file_name,\ - msg = "download failed", ei = session.extra_info: cb(a, fn, msg, ei) - self._callbacks.append(callback) - elif session.is_done: - self._logger.info(u"%s finished", session) - if session.success_callback: - callback = lambda cb = session.success_callback, a = session.address, fn = session.file_name,\ - fd = session.file_data, ei = session.extra_info: cb(a, fn, fd, ei) - self._callbacks.append(callback) - - self._schedule_callback_processing() - - def _handle_new_request(self, ip, port, packet): - """ Handles a new request. - :param ip: The IP of the client. - :param port: The port of the client. - :param packet: The packet. - """ - if packet['opcode'] != OPCODE_RRQ: - self._logger.error(u"Unexpected request from %s:%s, opcode=%s: packet=%s", - ip, port, packet['opcode'], repr(packet)) - return - if 'options' not in packet: - self._logger.error(u"No 'options' in request from %s:%s, opcode=%s, packet=%s", - ip, port, packet['opcode'], repr(packet)) - return - if 'blksize' not in packet['options'] or 'timeout' not in packet['options']: - self._logger.error(u"No 'blksize' or 'timeout' not in 'options' from %s:%s, opcode=%s, packet=%s", - ip, port, packet['opcode'], repr(packet)) - return - - file_name = packet['file_name'].decode('utf8') - block_size = packet['options']['blksize'] - timeout = packet['options']['timeout'] - - # check session_id - if (ip, port, packet['session_id']) in self._session_dict: - self._logger.warn(u"Existing session_id %s from %s:%s", packet['session_id'], ip, port) - dummy_session = Session(False, packet['session_id'], (ip, port), packet['opcode'], - file_name, None, None, None, block_size=block_size, timeout=timeout) - self._handle_error(dummy_session, 50) - return - - # read the file/directory into memory - try: - if file_name.startswith(METADATA_PREFIX): - if not self.session.config.get_metadata_enabled(): - return - file_data, file_size = self._load_metadata(file_name[len(METADATA_PREFIX):]) - else: - if not self.session.config.get_torrent_store_enabled(): - return - file_data, file_size = self._load_torrent(file_name) - checksum = b64encode(sha1(file_data).digest()) - except FileNotFound as e: - self._logger.warn(u"[READ %s:%s] file not found: %s", ip, port, e) - dummy_session = Session(False, packet['session_id'], (ip, port), packet['opcode'], - file_name, None, None, None, block_size=block_size, timeout=timeout) - self._handle_error(dummy_session, 1) - return - except Exception as e: - self._logger.error(u"[READ %s:%s] failed to load file: %s", ip, port, e) - dummy_session = Session(False, packet['session_id'], (ip, port), packet['opcode'], - file_name, None, None, None, block_size=block_size, timeout=timeout) - self._handle_error(dummy_session, 2) - raise - - # create a session object - session = Session(False, packet['session_id'], (ip, port), packet['opcode'], - file_name, file_data, file_size, checksum, block_size=block_size, timeout=timeout) - - # insert session_id and session - self._add_new_session(session) - self._logger.debug(u"got new request: %s", session) - - # send back OACK now - self._send_oack_packet(session) - - def _load_metadata(self, thumb_hash): - """ Loads a thumbnail into memory. - :param thumb_hash: The thumbnail hash. - """ - file_data = self.session.lm.metadata_store.get(thumb_hash.encode('utf8')) - # check if file exists - if not file_data: - msg = u"Metadata not in store: %s" % thumb_hash - raise FileNotFound(msg) - - return file_data, len(file_data) - - def _load_torrent(self, file_name): - """ Loads a file into memory. - :param file_name: The file name. - """ - infohash = (file_name[:-8]).encode('utf8') # len('.torrent') = 8 - - file_data = self.session.lm.torrent_store.get(infohash) - # check if file exists - if not file_data: - msg = u"Torrent not in store: %s" % infohash - raise FileNotFound(msg) - - return file_data, len(file_data) - - def _get_next_data(self, session): - """ Gets the next block of data to be uploaded. This method is only used for data uploading. - :return The data to transfer. - """ - start_idx = session.block_number * session.block_size - end_idx = start_idx + session.block_size - data = session.file_data[start_idx:end_idx] - session.block_number += 1 - - # check if we are done - if len(data) < session.block_size: - session.is_waiting_for_last_ack = True - - return data - - def _process_packet(self, session, packet): - """ processes an incoming packet. - :param packet: The incoming packet dictionary. - """ - session.last_contact_time = time() - # check if it is an ERROR packet - if packet['opcode'] == OPCODE_ERROR: - self._logger.warning(u"%s got ERROR message: code = %s, msg = %s", - session, packet['error_code'], packet['error_msg']) - session.is_failed = True - return - - # client is the receiver, server is the sender - if session.is_client: - self._handle_packet_as_receiver(session, packet) - else: - self._handle_packet_as_sender(session, packet) - - def _handle_packet_as_receiver(self, session, packet): - """ Processes an incoming packet as a receiver. - :param packet: The incoming packet dictionary. - """ - # if this is the first packet, check OACK - if packet['opcode'] == OPCODE_OACK: - if session.last_received_packet is None: - # check options - if session.block_size != packet['options']['blksize']: - msg = "%s OACK blksize mismatch: %s != %s (expected)" %\ - (session, session.block_size, packet['options']['blksize']) - self._logger.error(msg) - self._handle_error(session, 0, error_msg=msg) # Error: blksize mismatch - return - - if session.timeout != packet['options']['timeout']: - msg = "%s OACK timeout mismatch: %s != %s (expected)" %\ - (session, session.timeout, packet['options']['timeout']) - self._logger.error(msg) - self._handle_error(session, 0, error_msg=msg) # Error: timeout mismatch - return - - session.file_size = packet['options']['tsize'] - session.checksum = packet['options']['checksum'] - - if session.request == OPCODE_RRQ: - # send ACK - self._send_ack_packet(session, session.block_number) - session.block_number += 1 - session.file_data = "" - - else: - self._logger.error(u"%s Got OPCODE %s which is not expected", session, packet['opcode']) - self._handle_error(session, 4) # illegal TFTP operation - return - - # expect a DATA - if packet['opcode'] != OPCODE_DATA: - self._logger.error(u"%s Got OPCODE %s while expecting %s", session, packet['opcode'], OPCODE_DATA) - self._handle_error(session, 4) # illegal TFTP operation - return - - self._logger.debug(u"%s Got data, #block = %s size = %s", session, packet['block_number'], len(packet['data'])) - - # check block_number - # ignore old ones, they may be retransmissions - if packet['block_number'] < session.block_number: - self._logger.warn(u"%s ignore old block number DATA %s < %s", - session, packet['block_number'], session.block_number) - return - - if packet['block_number'] != session.block_number: - msg = "%s Got ACK with block# %s while expecting %s" %\ - (session, packet['block_number'], session.block_number) - self._logger.error(msg) - self._handle_error(session, 0, error_msg=msg) # Error: block_number mismatch - return - - # save data - session.file_data += packet['data'] - self._send_ack_packet(session, session.block_number) - session.block_number += 1 - - # check if it is the end - if len(packet['data']) < session.block_size: - self._logger.info(u"%s transfer finished. checking data integrity...", session) - # check file size and checksum - if session.file_size != len(session.file_data): - self._logger.error(u"%s file size %s doesn't match expectation %s", - session, len(session.file_data), session.file_size) - session.is_failed = True - return - - # compare checksum - data_checksum = b64encode(sha1(session.file_data).digest()) - if session.checksum != data_checksum: - self._logger.error(u"%s file checksum %s doesn't match expectation %s", - session, data_checksum, session.checksum) - session.is_failed = True - return - - session.is_done = True - - def _handle_packet_as_sender(self, session, packet): - """ Processes an incoming packet as a sender. - :param packet: The incoming packet dictionary. - """ - # expect an ACK packet - if packet['opcode'] != OPCODE_ACK: - self._logger.error(u"%s got OPCODE(%s) while expecting %s", session, packet['opcode'], OPCODE_ACK) - self._handle_error(session, 4) # illegal TFTP operation - return - - # check block number - # ignore old ones, they may be retransmissions - if packet['block_number'] < session.block_number: - self._logger.warn(u"%s ignore old block number ACK %s < %s", - session, packet['block_number'], session.block_number) - return - - if packet['block_number'] != session.block_number: - msg = "%s got ACK with block# %s while expecting %s" %\ - (session, packet['block_number'], session.block_number) - self._logger.error(msg) - self._handle_error(session, 0, error_msg=msg) # Error: block_number mismatch - return - - if session.is_waiting_for_last_ack: - session.is_done = True - return - - data = self._get_next_data(session) - # send DATA - self._send_data_packet(session, session.block_number, data) - - def _handle_error(self, session, error_code, error_msg=""): - """ Handles an error during packet processing. - :param error_code: The error code. - """ - session.is_failed = True - msg = error_msg if error_msg else ERROR_DICT.get(error_code, error_msg) - self._send_error_packet(session, error_code, msg) - - def _send_packet(self, session, packet): - packet_buff = encode_packet(packet) - extra_msg = u" block_number = %s" % packet['block_number'] if packet.get('block_number') is not None else "" - extra_msg += u" block_size = %s" % len(packet['data']) if packet.get('data') is not None else "" - - self._logger.debug(u"SEND OP[%s] -> %s:%s %s", - packet['opcode'], session.address[0], session.address[1], extra_msg) - self._endpoint.send_packet(Candidate(session.address, False), packet_buff, prefix=self._prefix) - - # update information - session.last_contact_time = time() - session.last_sent_packet = packet - - def _send_request_packet(self, session): - assert session.request == OPCODE_RRQ, u"Invalid request_opcode %s" % repr(session.request) - - packet = {'opcode': session.request, - 'session_id': session.session_id, - 'file_name': session.file_name.encode('utf8'), - 'options': {'blksize': session.block_size, - 'timeout': session.timeout, - }} - self._send_packet(session, packet) - - def _send_data_packet(self, session, block_number, data): - packet = {'opcode': OPCODE_DATA, - 'session_id': session.session_id, - 'block_number': block_number, - 'data': data} - self._send_packet(session, packet) - - def _send_ack_packet(self, session, block_number): - packet = {'opcode': OPCODE_ACK, - 'session_id': session.session_id, - 'block_number': block_number} - self._send_packet(session, packet) - - def _send_error_packet(self, session, error_code, error_msg): - packet = {'opcode': OPCODE_ERROR, - 'session_id': session.session_id, - 'error_code': error_code, - 'error_msg': error_msg - } - self._send_packet(session, packet) - - def _send_oack_packet(self, session): - packet = {'opcode': OPCODE_OACK, - 'session_id': session.session_id, - 'block_number': session.block_number, - 'options': {'blksize': session.block_size, - 'timeout': session.timeout, - 'tsize': session.file_size, - 'checksum': session.checksum, - }} - self._send_packet(session, packet) diff --git a/Tribler/Core/TFTP/packet.py b/Tribler/Core/TFTP/packet.py deleted file mode 100644 index 212531b4b47..00000000000 --- a/Tribler/Core/TFTP/packet.py +++ /dev/null @@ -1,231 +0,0 @@ -import struct -from binascii import hexlify - -from .exception import InvalidStringException, InvalidPacketException - -# OPCODE -OPCODE_RRQ = 1 -OPCODE_WRQ = 2 -OPCODE_DATA = 3 -OPCODE_ACK = 4 -OPCODE_ERROR = 5 -OPCODE_OACK = 6 - -# supported options -OPTIONS = ("blksize", "timeout", "tsize", "checksum") - -# error codes and messages -ERROR_DICT = { - 0: "Not defined, see error message (if any).", - 1: "File not found", - 2: "Access violation", - 3: "Disk full or allocation exceeded", - 4: "Illegal TFTP operation", - 5: "Unknown transfer ID", - 6: "File already exists", - 7: "No such user", - 8: "Failed to negotiate options", - 50: "Session ID already exists", -} - - -def _get_string(buff, start_idx): - """ Gets a zero-terminated string from a given buffer. - :param buff: The buffer. - :param start_idx: The index to start from. - :return: A (str, idx) tuple that has the zero-terminated string and the next index. - """ - str_data = "" - next_idx = start_idx + 1 - got_end = False - for c in buff[start_idx:]: - if ord(c) == 0: - got_end = True - break - str_data += c - next_idx += 1 - - if not got_end: - raise InvalidStringException() - return str_data, next_idx - - -def _decode_options(packet, buff, start_idx): - """ Decodes options from a given packet buffer. - :param packet: The packet dictionary to use. - :param buff: The packet buffer. - :param start_idx: The index to start from. - :return: None - """ - packet['options'] = {} - idx = start_idx - while idx < len(buff): - option, idx = _get_string(buff, idx) - value, idx = _get_string(buff, idx) - if option == "": - raise InvalidPacketException(u"Empty option") - if value == "": - raise InvalidPacketException(u"Empty value for option[%s]" % repr(option)) - - packet['options'][option] = value - - # validate options and convert them to proper format - for k, v in packet['options'].items(): - if k not in OPTIONS: - raise InvalidPacketException(u"Unknown option[%s]" % repr(k)) - - # blksize, timeout, and tsize are all integers - try: - if k in ("blksize", "timeout", "tsize"): - packet['options'][k] = int(v) - else: - packet['options'][k] = v - except ValueError: - raise InvalidPacketException(u"Invalid value for option %s: %s" % (repr(k), repr(v))) - - -def _decode_rrq_wrq(packet, packet_buff, offset): - """ Decodes a RRQ/WRQ packet. - :param packet: The packet dictionary. - :param packet_buff: The packet buffer. - :return: The decoded packet as a dictionary. - """ - # get file_name and mode - file_name, idx = _get_string(packet_buff, offset) - - packet['file_name'] = file_name - - # get options - _decode_options(packet, packet_buff, idx) - return packet - - -def _decode_data(packet, packet_buff, offset): - """ Decodes a DATA packet. - :param packet: The packet dictionary. - :param packet_buff: The packet buffer. - :return: The decoded packet as a dictionary. - """ - # get block number and data - if len(packet_buff) < offset + 2: - raise InvalidPacketException(u"DATA packet too small (<4): %s" % repr(packet_buff)) - block_number, = struct.unpack_from("!H", packet_buff, offset) - data = packet_buff[offset + 2:] - - packet['block_number'] = block_number - packet['data'] = data - - return packet - - -def _decode_ack(packet, packet_buff, offset): - """ Decodes a ACK packet. - :param packet: The packet dictionary. - :param packet_buff: The packet buffer. - :return: The decoded packet as a dictionary. - """ - # get block number - if len(packet_buff) != offset + 2: - raise InvalidPacketException(u"ACK packet has invalid size (!=%s): %s" % (offset + 2, hexlify(packet_buff))) - block_number, = struct.unpack_from("!H", packet_buff, offset) - - packet['block_number'] = block_number - - return packet - - -def _decode_error(packet, packet_buff, offset): - """ Decodes a ERROR packet. - :param packet: The packet dictionary. - :param packet_buff: The packet buffer. - :return: The decoded packet as a dictionary. - """ - if len(packet_buff) < offset + 3: - raise InvalidPacketException(u"ERROR packet too small (<%s): %s" % (offset + 3, hexlify(packet_buff))) - error_code, = struct.unpack_from("!H", packet_buff, offset) - error_msg, idx = _get_string(packet_buff, offset + 2) - - if not error_msg: - raise InvalidPacketException(u"ERROR packet has empty error message: %s" % hexlify(packet_buff)) - if idx != len(packet_buff): - raise InvalidPacketException(u"Invalid ERROR packet: %s" % hexlify(packet_buff)) - - packet['error_code'] = error_code - packet['error_msg'] = error_msg - - return packet - - -def _decode_oack(packet, packet_buff, offset): - """ Decodes a OACK packet. - :param packet: The packet dictionary. - :param packet_buff: The packet buffer. - :return: The decoded packet as a dictionary. - """ - # get block number and data - _decode_options(packet, packet_buff, offset) - - return packet - - -PACKET_DECODE_DICT = { - OPCODE_RRQ: _decode_rrq_wrq, - OPCODE_WRQ: _decode_rrq_wrq, - OPCODE_DATA: _decode_data, - OPCODE_ACK: _decode_ack, - OPCODE_ERROR: _decode_error, - OPCODE_OACK: _decode_oack, -} - - -# =================================================================================== -# Public APIs for encoding and decoding -# =================================================================================== -def decode_packet(packet_buff): - """ Decodes a packet binary string into a packet dictionary. - :param packet_buff: The packet binary string. - :return: The decoded packet dictionary. - """ - # get the opcode - if len(packet_buff) < 4: - raise InvalidPacketException(u"Packet too small (<4): %s" % hexlify(packet_buff)) - opcode, session_id = struct.unpack_from("!HH", packet_buff, 0) - - if opcode not in PACKET_DECODE_DICT: - raise InvalidPacketException(u"Invalid opcode: %s" % opcode) - - # decode the packet - packet = {'opcode': opcode, - 'session_id': session_id} - return PACKET_DECODE_DICT[opcode](packet, packet_buff, 4) - - -def encode_packet(packet): - """ Encodes a packet dictionary into a binary string. - :param packet: The packet dictionary. - :return: The encoded packet buffer. - """ - # get block number and data - packet_buff = struct.pack("!HH", packet['opcode'], packet['session_id']) - if packet['opcode'] in (OPCODE_RRQ, OPCODE_WRQ): - packet_buff += packet['file_name'] + "\x00" - - for k, v in packet['options'].iteritems(): - packet_buff += "%s\x00%s\x00" % (k, v) - - elif packet['opcode'] == OPCODE_DATA: - packet_buff += struct.pack("!H", packet['block_number']) - packet_buff += packet['data'] - - elif packet['opcode'] == OPCODE_ACK: - packet_buff += struct.pack("!H", packet['block_number']) - - elif packet['opcode'] == OPCODE_ERROR: - packet_buff += struct.pack("!H", packet['error_code']) - packet_buff += packet['error_msg'] + "\x00" - - elif packet['opcode'] == OPCODE_OACK: - for k, v in packet['options'].iteritems(): - packet_buff += "%s\x00%s\x00" % (k, v) - - return packet_buff diff --git a/Tribler/Core/TFTP/session.py b/Tribler/Core/TFTP/session.py deleted file mode 100644 index 1f94a93b802..00000000000 --- a/Tribler/Core/TFTP/session.py +++ /dev/null @@ -1,52 +0,0 @@ -from time import time - - -# default packet data size -DEFAULT_BLOCK_SIZE = 512 - -# default timeout and maximum retries -DEFAULT_TIMEOUT = 2 - - -class Session(object): - - def __init__(self, is_client, session_id, address, request, file_name, file_data, file_size, checksum, - extra_info=None, block_size=DEFAULT_BLOCK_SIZE, timeout=DEFAULT_TIMEOUT, - success_callback=None, failure_callback=None): - self.is_client = is_client - self.session_id = session_id - self.address = address - self.request = request - self.file_name = file_name - self.file_data = file_data - self.file_size = file_size - self.checksum = checksum - - self.extra_info = extra_info - - self.block_number = 0 - self.block_size = block_size - self.timeout = timeout - self.success_callback = success_callback - self.failure_callback = failure_callback - - self.last_contact_time = time() - self.last_received_packet = None - self.last_sent_packet = None - self.is_waiting_for_last_ack = False - - self.retries = 0 - - self.is_done = False - self.is_failed = False - - self.next_func = None - - def __str__(self): - type_str = "C" if self.is_client else "S" - return "TFTP[%s %s %s:%s][%s]" % (self.session_id, type_str, self.address[0], self.address[1], - self.file_name.encode('utf8')) - - def __unicode__(self): - type_str = u"C" if self.is_client else u"S" - return u"TFTP[%s %s %s:%s][%s]" % (self.session_id, type_str, self.address[0], self.address[1], self.file_name) diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index 3328274c99b..9534f245c1b 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -1,9 +1,11 @@ +from __future__ import absolute_import + import logging import socket import time -from Tribler.Core.Utilities.utilities import is_valid_url from binascii import hexlify +from pony.orm import db_session from twisted.internet import reactor from twisted.internet.defer import DeferredList, CancelledError, fail, succeed, maybeDeferred from twisted.internet.error import ConnectingCancelledError, ConnectionLost @@ -12,8 +14,9 @@ from Tribler.Core.TorrentChecker.session import create_tracker_session, FakeDHTSession, UdpSocketManager from Tribler.Core.Utilities.tracker_utils import MalformedTrackerURLException -from Tribler.Core.simpledefs import NTFY_TORRENTS +from Tribler.Core.Utilities.utilities import is_valid_url from Tribler.community.popularity.repository import TYPE_TORRENT_HEALTH +from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.taskmanager import TaskManager # some settings @@ -31,8 +34,6 @@ def __init__(self, session): self._logger = logging.getLogger(self.__class__.__name__) self.tribler_session = session - self._torrent_db = None - self._should_stop = False self._torrent_check_interval = DEFAULT_TORRENT_CHECK_INTERVAL @@ -49,7 +50,6 @@ def __init__(self, session): self.connection_pool = None def initialize(self): - self._torrent_db = self.tribler_session.open_dbhandler(NTFY_TORRENTS) self._reschedule_tracker_select() self.connection_pool = HTTPConnectionPool(reactor, False) self.socket_mgr = UdpSocketManager() @@ -99,16 +99,8 @@ def _reschedule_tracker_select(self): """ Changes the tracker selection interval dynamically and schedules the task. """ - # dynamically change the interval: update at least every 2h - num_torrents = self._torrent_db.getNumberCollectedTorrents() - - tracker_select_interval = min(max(7200 / num_torrents, 10), 100) if num_torrents \ - else DEFAULT_TORRENT_SELECTION_INTERVAL - - self._logger.debug(u"tracker selection interval changed to %s", tracker_select_interval) - self.register_task(u"torrent_checker_tracker_selection", - reactor.callLater(tracker_select_interval, self._task_select_tracker)) + reactor.callLater(DEFAULT_TORRENT_SELECTION_INTERVAL, self._task_select_tracker)) def _task_select_tracker(self): """ @@ -127,7 +119,16 @@ def _task_select_tracker(self): self._logger.debug(u"Start selecting torrents on tracker %s.", tracker_url) # get the torrents that should be checked - infohashes = self._torrent_db.getTorrentsOnTracker(tracker_url, int(time.time())) + infohashes = [] + with db_session: + tracker = list(self.tribler_session.lm.mds.TrackerState.select(lambda g: str(g.url) == tracker_url)) + if tracker: + tracker = tracker[0] + torrents = tracker.torrents + for torrent in torrents: + dynamic_interval = self._torrent_check_retry_interval * (2 ** tracker.failures) + if torrent.last_check + dynamic_interval < int(time.time()): + infohashes.append(torrent.infohash) if len(infohashes) == 0: # We have not torrent to recheck for this tracker. Still update the last_check for this tracker. @@ -171,15 +172,18 @@ def remove_tracker(self, tracker_url): def update_tracker_info(self, tracker_url, value): self.tribler_session.lm.tracker_manager.update_tracker_info(tracker_url, value) + @db_session def get_valid_trackers_of_torrent(self, torrent_id): """ Get a set of valid trackers for torrent. Also remove any invalid torrent.""" - db_tracker_list = self._torrent_db.getTrackerListByTorrentID(torrent_id) - return set([tracker for tracker in db_tracker_list if is_valid_url(tracker) or tracker == u'DHT']) + db_tracker_list = list(self.tribler_session.lm.mds.TorrentState.select( + lambda g: g.infohash == database_blob(torrent_id)))[0].trackers + return set([str(tracker.url) for tracker in db_tracker_list if + is_valid_url(str(tracker.url)) or str(tracker.url) == u'DHT']) def on_gui_request_completed(self, infohash, result): final_response = {} - torrent_update_dict = {'infohash': infohash, 'seeders': 0, 'leechers': 0, 'last_check': time.time()} + torrent_update_dict = {'infohash': infohash, 'seeders': 0, 'leechers': 0, 'last_check': int(time.time())} for success, response in result: if not success and isinstance(response, Failure): final_response[response.tracker_url] = {'error': response.getErrorMessage()} @@ -195,7 +199,7 @@ def on_gui_request_completed(self, infohash, result): final_response[response.keys()[0]] = response[response.keys()[0]][0] - self._update_torrent_result(torrent_update_dict) + self._update_torrent_result(torrent_update_dict, final_response) # Add this result to popularity community to publish to subscribers self.publish_torrent_result(torrent_update_dict) @@ -209,43 +213,45 @@ def add_gui_request(self, infohash, timeout=20, scrape_now=False): :param timeout: The timeout to use in the performed requests :param scrape_now: Flag whether we want to force scraping immediately """ - result = self._torrent_db.getTorrent(infohash, (u'torrent_id', u'last_tracker_check', - u'num_seeders', u'num_leechers'), False) - if result is None: - self._logger.warn(u"torrent info not found, skip. infohash: %s", hexlify(infohash)) - return fail(Failure(RuntimeError("Torrent not found"))) - - torrent_id = result[u'torrent_id'] - last_check = result[u'last_tracker_check'] - time_diff = time.time() - last_check - if time_diff < self._torrent_check_interval and not scrape_now: - self._logger.debug(u"time interval too short, skip GUI request. infohash: %s", hexlify(infohash)) - return succeed({"db": {"seeders": result[u'num_seeders'], - "leechers": result[u'num_leechers'], "infohash": infohash.encode('hex')}}) - - # get torrent's tracker list from DB - tracker_set = self.get_valid_trackers_of_torrent(torrent_id) - if not tracker_set: - self._logger.warn(u"no trackers, skip GUI request. infohash: %s", hexlify(infohash)) - # TODO: add code to handle torrents with no tracker - return fail(Failure(RuntimeError("No trackers available for this torrent"))) - - deferred_list = [] - for tracker_url in tracker_set: - if tracker_url == u'DHT': - # Create a (fake) DHT session for the lookup - session = FakeDHTSession(self.tribler_session, infohash, timeout) - self._session_list['DHT'].append(session) - deferred_list.append(session.connect_to_tracker(). - addCallbacks(*self.get_callbacks_for_session(session))) - elif tracker_url != u'no-DHT': - session = self._create_session_for_request(tracker_url, timeout=timeout) - session.add_infohash(infohash) - deferred_list.append(session.connect_to_tracker(). - addCallbacks(*self.get_callbacks_for_session(session))) - - return DeferredList(deferred_list, consumeErrors=True).addCallback( - lambda res: self.on_gui_request_completed(infohash, res)) + with db_session: + result = list(self.tribler_session.lm.mds.TorrentState.select( + lambda g: g.infohash == database_blob(infohash))) + if not result: + self._logger.warn(u"torrent info not found, skip. infohash: %s", hexlify(infohash)) + return fail(Failure(RuntimeError("Torrent not found"))) + result = result[0] + + torrent_id = str(result.infohash) + last_check = result.last_check + time_diff = time.time() - last_check + if time_diff < self._torrent_check_interval and not scrape_now: + self._logger.debug(u"time interval too short, skip GUI request. infohash: %s", hexlify(infohash)) + return succeed({"db": {"seeders": result.seeders, + "leechers": result.leechers, "infohash": hexlify(infohash)}}) + + # get torrent's tracker list from DB + tracker_set = self.get_valid_trackers_of_torrent(torrent_id) + if not tracker_set: + self._logger.warn(u"no trackers, skip GUI request. infohash: %s", hexlify(infohash)) + # TODO: add code to handle torrents with no tracker + return fail(Failure(RuntimeError("No trackers available for this torrent"))) + + deferred_list = [] + for tracker_url in tracker_set: + if tracker_url == u'DHT': + # Create a (fake) DHT session for the lookup + session = FakeDHTSession(self.tribler_session, infohash, timeout) + self._session_list['DHT'].append(session) + deferred_list.append(session.connect_to_tracker(). + addCallbacks(*self.get_callbacks_for_session(session))) + elif tracker_url != u'no-DHT': + session = self._create_session_for_request(tracker_url, timeout=timeout) + session.add_infohash(infohash) + deferred_list.append(session.connect_to_tracker(). + addCallbacks(*self.get_callbacks_for_session(session))) + + return DeferredList(deferred_list, consumeErrors=True).addCallback( + lambda res: self.on_gui_request_completed(infohash, res)) def on_session_error(self, session, failure): """ @@ -295,7 +301,7 @@ def _on_result_from_session(self, session, result_list): return result_list - def _update_torrent_result(self, response): + def _update_torrent_result(self, response, update_dict): infohash = response['infohash'] seeders = response['seeders'] leechers = response['leechers'] @@ -304,29 +310,19 @@ def _update_torrent_result(self, response): # the torrent status logic, TODO: do it in other way self._logger.debug(u"Update result %s/%s for %s", seeders, leechers, hexlify(infohash)) - result = self._torrent_db.getTorrent(infohash, (u'torrent_id', u'tracker_check_retries'), include_mypref=False) - torrent_id = result[u'torrent_id'] - retries = result[u'tracker_check_retries'] - - # the status logic - if seeders > 0: - retries = 0 - status = u'good' - else: - retries += 1 - if retries < self._max_torrent_check_retries: - status = u'unknown' - else: - status = u'dead' - # prevent retries from exceeding the maximum - retries = self._max_torrent_check_retries - - # calculate next check time: + * (2 ^ ) - next_check = last_check + self._torrent_check_retry_interval * (2 ** retries) - - self._torrent_db.updateTorrentCheckResult(torrent_id, - infohash, seeders, leechers, last_check, next_check, - status, retries) + with db_session: + result = list(self.tribler_session.lm.mds.TorrentState.select( + lambda g: g.infohash == database_blob(infohash)))[0] + for tracker in result.trackers: + tracker.last_check = int(time.time()) + if update_dict.get(tracker.url, {'seeders': 0, 'leechers': 0}) > 0: + tracker.alive = True + tracker.failures = 0 + else: + tracker.failures = min(tracker.failures + 1, self._max_torrent_check_retries) + result.seeders = seeders + result.leechers = leechers + result.last_check = last_check def publish_torrent_result(self, response): if response['seeders'] == 0: diff --git a/Tribler/Core/Upgrade/config_converter.py b/Tribler/Core/Upgrade/config_converter.py index efdce05cf62..09b8287c3e7 100644 --- a/Tribler/Core/Upgrade/config_converter.py +++ b/Tribler/Core/Upgrade/config_converter.py @@ -133,18 +133,8 @@ def add_libtribler_config(new_config, old_config): temp_config.set_state_dir(value) elif section == "general" and name == "eckeypairfilename": temp_config.set_permid_keypair_filename(value) - elif section == "general" and name == "megacache": - temp_config.set_megacache_enabled(value) elif section == "general" and name == "log_dir": temp_config.set_log_dir(value) - elif section == "allchannel_community" and name == "enabled": - temp_config.set_channel_search_enabled(value) - elif section == "channel_community" and name == "enabled": - temp_config.set_channel_community_enabled(value) - elif section == "preview_channel_community" and name == "enabled": - temp_config.set_preview_channel_community_enabled(value) - elif section == "search_community" and name == "enabled": - temp_config.set_torrent_search_enabled(value) elif section == "tunnel_community" and name == "enabled": temp_config.set_tunnel_community_enabled(value) elif section == "tunnel_community" and name == "socks5_listen_ports": @@ -154,26 +144,12 @@ def add_libtribler_config(new_config, old_config): temp_config.set_tunnel_community_exitnode_enabled(value) elif section == "general" and name == "ec_keypair_filename_multichain": temp_config.set_trustchain_keypair_filename(value) - elif section == "metadata" and name == "enabled": - temp_config.set_metadata_enabled(value) - elif section == "metadata" and name == "store_dir": - temp_config.set_metadata_store_dir(value) elif section == "mainline_dht" and name == "enabled": temp_config.set_mainline_dht_enabled(value) elif section == "mainline_dht" and name == "mainline_dht_port": temp_config.set_mainline_dht_port(value) elif section == "torrent_checking" and name == "enabled": temp_config.set_torrent_checking_enabled(value) - elif section == "torrent_store" and name == "enabled": - temp_config.set_torrent_store_enabled(value) - elif section == "torrent_store" and name == "dir": - temp_config.set_torrent_store_dir(value) - elif section == "torrent_collecting" and name == "enabled": - temp_config.set_torrent_collecting_enabled(value) - elif section == "torrent_collecting" and name == "torrent_collecting_max_torrents": - temp_config.set_torrent_collecting_max_torrents(value) - elif section == "torrent_collecting" and name == "torrent_collecting_dir": - temp_config.set_torrent_collecting_dir(value) elif section == "libtorrent" and name == "lt_proxytype": temp_config.config["libtorrent"]["proxy_type"] = value elif section == "libtorrent" and name == "lt_proxyserver": @@ -198,10 +174,6 @@ def add_libtribler_config(new_config, old_config): temp_config.config["libtorrent"]["anon_proxy_server_ports"] = [str(port) for port in value[1]] elif section == "libtorrent" and name == "anon_proxyauth": temp_config.config["libtorrent"]["anon_proxy_auth"] = value - elif section == "dispersy" and name == "enabled": - temp_config.set_dispersy_enabled(value) - elif section == "dispersy" and name == "dispersy_port": - temp_config.set_dispersy_port(value) elif section == "video" and name == "enabled": temp_config.set_video_server_enabled(value) elif section == "video" and name == "port": diff --git a/Tribler/Core/Upgrade/db_upgrader.py b/Tribler/Core/Upgrade/db_upgrader.py deleted file mode 100644 index 4ab191c9839..00000000000 --- a/Tribler/Core/Upgrade/db_upgrader.py +++ /dev/null @@ -1,588 +0,0 @@ -""" -Upgrades the database from one version to a newer version. - -Author(s): Elric Milon -""" -import logging -import os -from binascii import hexlify -from shutil import rmtree -from sqlite3 import Connection - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler -from Tribler.Core.CacheDB.db_versions import LOWEST_SUPPORTED_DB_VERSION, LATEST_DB_VERSION -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Core.Category.Category import Category -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.Utilities.search_utils import split_into_keywords - - -class VersionNoLongerSupportedError(Exception): - pass - - -class DatabaseUpgradeError(Exception): - pass - - -class DBUpgrader(object): - - """ - Migration tool for upgrading the collected torrent files/thumbnails on disk - structure from Tribler version 6.3 to 6.4. - """ - - def __init__(self, session, db, torrent_store, status_update_func=None): - self._logger = logging.getLogger(self.__class__.__name__) - self.session = session - self.db = db - self.status_update_func = status_update_func if status_update_func else lambda _: None - self.torrent_store = torrent_store - - self.failed = True - self.torrent_collecting_dir = self.session.config.get_torrent_collecting_dir() - - def start_migrate(self): - """ - Starts migrating from Tribler 6.3 to 6.4. - """ - - if self.db.version == 17: - self._upgrade_17_to_18() - - # version 18 -> 22 - if self.db.version == 18: - self._upgrade_18_to_22() - - # version 22 -> 23 - if self.db.version == 22: - self._upgrade_22_to_23() - - # version 23 -> 24 (24 is a dummy version in which we only cleans up thumbnail files - if self.db.version == 23: - self._upgrade_23_to_24() - - # version 24 -> 25 (25 is also a dummy version, where the torrent files get migrated to a levedb based store. - if self.db.version == 24: - self._upgrade_24_to_25() - - # version 25 -> 26 - if self.db.version == 25: - self._upgrade_25_to_26() - - # version 26 -> 27 - if self.db.version == 26: - self._upgrade_26_to_27() - - # version 27 -> 28 - if self.db.version == 27: - self._upgrade_27_to_28() - - # version 28 -> 29 - if self.db.version == 28: - self._upgrade_28_to_29() - - # check if we managed to upgrade to the latest DB version. - if self.db.version == LATEST_DB_VERSION: - self.status_update_func(u"Database upgrade finished.") - self.failed = False - else: - if self.db.version < LOWEST_SUPPORTED_DB_VERSION: - msg = u"Database is too old %s < %s" % (self.db.version, LOWEST_SUPPORTED_DB_VERSION) - self.status_update_func(msg) - raise VersionNoLongerSupportedError(msg) - else: - msg = u"Database upgrade failed: %s -> %s" % (self.db.version, LATEST_DB_VERSION) - self.status_update_func(msg) - raise DatabaseUpgradeError(msg) - - def _purge_old_search_metadata_communities(self): - """ - Cleans up all SearchCommunity and MetadataCommunity stuff in dispersy database. - """ - db_path = os.path.join(self.session.config.get_state_dir(), u"sqlite", u"dispersy.db") - if not os.path.isfile(db_path): - return - - communities_to_delete = (u"SearchCommunity", u"MetadataCommunity", u"TunnelCommunity") - - connection = Connection(db_path) - cursor = connection.cursor() - - for community in communities_to_delete: - try: - result = list(cursor.execute(u"SELECT id FROM community WHERE classification == ?;", (community,))) - - for community_id, in result: - cursor.execute(u"DELETE FROM community WHERE id == ?;", (community_id,)) - cursor.execute(u"DELETE FROM meta_message WHERE community == ?;", (community_id,)) - cursor.execute(u"DELETE FROM sync WHERE community == ?;", (community_id,)) - except StopIteration: - continue - - cursor.close() - connection.commit() - connection.close() - - def _upgrade_17_to_18(self): - self.current_status = u"Upgrading database from v%s to v%s..." % (17, 18) - - self.db.execute(u""" -DROP TABLE IF EXISTS BarterCast; -DROP INDEX IF EXISTS bartercast_idx; -INSERT OR IGNORE INTO MetaDataTypes ('name') VALUES ('swift-thumbnails'); -INSERT OR IGNORE INTO MetaDataTypes ('name') VALUES ('video-info'); -""") - # update database version - self.db.write_version(18) - - def _upgrade_18_to_22(self): - self.current_status = u"Upgrading database from v%s to v%s..." % (18, 22) - - self.db.execute(u""" -DROP INDEX IF EXISTS Torrent_swift_hash_idx; - -DROP VIEW IF EXISTS Friend; -DROP VIEW IF EXISTS SuperPeer; - -ALTER TABLE Peer RENAME TO __Peer_tmp; -CREATE TABLE IF NOT EXISTS Peer ( - peer_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - permid text NOT NULL, - name text, - thumbnail text -); - -INSERT INTO Peer (peer_id, permid, name, thumbnail) SELECT peer_id, permid, name, thumbnail FROM __Peer_tmp; - -DROP TABLE IF EXISTS __Peer_tmp; - -ALTER TABLE Torrent ADD COLUMN last_tracker_check integer DEFAULT 0; -ALTER TABLE Torrent ADD COLUMN tracker_check_retries integer DEFAULT 0; -ALTER TABLE Torrent ADD COLUMN next_tracker_check integer DEFAULT 0; - -CREATE TABLE IF NOT EXISTS TrackerInfo ( - tracker_id integer PRIMARY KEY AUTOINCREMENT, - tracker text UNIQUE NOT NULL, - last_check numeric DEFAULT 0, - failures integer DEFAULT 0, - is_alive integer DEFAULT 1 -); - -CREATE TABLE IF NOT EXISTS TorrentTrackerMapping ( - torrent_id integer NOT NULL, - tracker_id integer NOT NULL, - FOREIGN KEY (torrent_id) REFERENCES Torrent(torrent_id), - FOREIGN KEY (tracker_id) REFERENCES TrackerInfo(tracker_id), - PRIMARY KEY (torrent_id, tracker_id) -); - -INSERT OR IGNORE INTO TrackerInfo (tracker) VALUES ('no-DHT'); -INSERT OR IGNORE INTO TrackerInfo (tracker) VALUES ('DHT'); - -DROP INDEX IF EXISTS torrent_biterm_phrase_idx; -DROP TABLE IF EXISTS TorrentBiTermPhrase; -DROP INDEX IF EXISTS termfrequency_freq_idx; -DROP TABLE IF EXISTS TermFrequency; -DROP INDEX IF EXISTS Torrent_insert_idx; -DROP INDEX IF EXISTS Torrent_info_roothash_idx; - -DROP TABLE IF EXISTS ClicklogSearch; -DROP INDEX IF EXISTS idx_search_term; -DROP INDEX IF EXISTS idx_search_torrent; -""") - # update database version - self.db.write_version(22) - - def _upgrade_22_to_23(self): - """ - Migrates the database to the new version. - """ - self.status_update_func(u"Upgrading database from v%s to v%s..." % (22, 23)) - - self.db.execute(u""" -DROP TABLE IF EXISTS BarterCast; -DROP INDEX IF EXISTS bartercast_idx; - -DROP INDEX IF EXISTS Torrent_swift_torrent_hash_idx; -""") - - try: - next(self.db.execute(u"SELECT * From sqlite_master WHERE name == '_tmp_Torrent' and type == 'table';")) - - except StopIteration: - # no _tmp_Torrent table, check if the current Torrent table is new - lines = [(0, u'torrent_id', u'integer', 1, None, 1), - (1, u'infohash', u'text', 1, None, 0), - (2, u'name', u'text', 0, None, 0), - (3, u'torrent_file_name', u'text', 0, None, 0), - (4, u'length', u'integer', 0, None, 0), - (5, u'creation_date', u'integer', 0, None, 0), - (6, u'num_files', u'integer', 0, None, 0), - (7, u'thumbnail', u'integer', 0, None, 0), - (8, u'insert_time', u'numeric', 0, None, 0), - (9, u'secret', u'integer', 0, None, 0), - (10, u'relevance', u'numeric', 0, u'0', 0), - (11, u'source_id', u'integer', 0, None, 0), - (12, u'category_id', u'integer', 0, None, 0), - (13, u'status_id', u'integer', 0, u'0', 0), - (14, u'num_seeders', u'integer', 0, None, 0), - (15, u'num_leechers', u'integer', 0, None, 0), - (16, u'comment', u'text', 0, None, 0), - (17, u'dispersy_id', u'integer', 0, None, 0), - (18, u'last_tracker_check', u'integer', 0, u'0', 0), - (19, u'tracker_check_retries', u'integer', 0, u'0', 0), - (20, u'next_tracker_check', u'integer', 0, u'0', 0) - ] - i = 0 - is_new = True - for line in self.db.execute(u"PRAGMA table_info(Torrent);"): - if line != lines[i]: - is_new = False - break - i += 1 - - if not is_new: - # create the temporary table - self.db.execute(u""" -CREATE TABLE IF NOT EXISTS _tmp_Torrent ( - torrent_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - infohash text NOT NULL, - name text, - torrent_file_name text, - length integer, - creation_date integer, - num_files integer, - thumbnail integer, - insert_time numeric, - secret integer, - relevance numeric DEFAULT 0, - source_id integer, - category_id integer, - status_id integer DEFAULT 0, - num_seeders integer, - num_leechers integer, - comment text, - dispersy_id integer, - last_tracker_check integer DEFAULT 0, - tracker_check_retries integer DEFAULT 0, - next_tracker_check integer DEFAULT 0 -); -""") - - # migrate Torrent table - keys = (u"torrent_id", u"infohash", u"name", u"torrent_file_name", u"length", u"creation_date", - u"num_files", u"thumbnail", u"insert_time", u"secret", u"relevance", u"source_id", - u"category_id", u"status_id", u"num_seeders", u"num_leechers", u"comment", u"dispersy_id", - u"last_tracker_check", u"tracker_check_retries", u"next_tracker_check") - - keys_str = u", ".join(keys) - values_str = u"?," * len(keys) - insert_stmt = u"INSERT INTO _tmp_Torrent(%s) VALUES(%s)" % (keys_str, values_str[:-1]) - current_count = 0 - - results = self.db.execute(u"SELECT %s FROM Torrent;" % keys_str) - new_torrents = [] - for torrent in results: - torrent_id, infohash, name, torrent_file_name = torrent[:4] - - filepath = os.path.join(self.torrent_collecting_dir, hexlify(str2bin(infohash)) + u".torrent") - - # Check if we have the actual .torrent - torrent_file_name = None - if os.path.exists(filepath): - torrent_file_name = filepath - tdef = TorrentDef.load(filepath) - # Use the name on the .torrent file instead of the one stored in the database. - name = tdef.get_name_as_unicode() or name - - new_torrents.append((torrent_id, infohash, name, torrent_file_name) + torrent[4:]) - - current_count += 1 - self.status_update_func(u"Upgrading database, %s records upgraded..." % current_count) - - self.status_update_func(u"All torrent entries processed, inserting in database...") - self.db.executemany(insert_stmt, new_torrents) - self.status_update_func(u"All updated torrent entries inserted.") - - self.db.execute(u""" -DROP VIEW IF EXISTS CollectedTorrent; -DROP TABLE IF EXISTS Torrent; -ALTER TABLE _tmp_Torrent RENAME TO Torrent; -CREATE VIEW CollectedTorrent AS SELECT * FROM Torrent WHERE torrent_file_name IS NOT NULL; -""") - - # cleanup metadata tables - self.db.execute(u""" -DROP TABLE IF EXISTS MetadataMessage; -DROP TABLE IF EXISTS MetadataData; - -CREATE TABLE IF NOT EXISTS MetadataMessage ( - message_id INTEGER PRIMARY KEY AUTOINCREMENT, - dispersy_id INTEGER NOT NULL, - this_global_time INTEGER NOT NULL, - this_mid TEXT NOT NULL, - infohash TEXT NOT NULL, - previous_mid TEXT, - previous_global_time INTEGER -); - -CREATE TABLE IF NOT EXISTS MetadataData ( - message_id INTEGER, - data_key TEXT NOT NULL, - data_value INTEGER, - FOREIGN KEY (message_id) REFERENCES MetadataMessage(message_id) ON DELETE CASCADE -); -""") - - # cleanup all SearchCommunity and MetadataCommunity data in dispersy database - self._purge_old_search_metadata_communities() - - # update database version - self.db.write_version(23) - - def _upgrade_23_to_24(self): - self.status_update_func(u"Upgrading database from v%s to v%s..." % (23, 24)) - - # remove all thumbnail files - for root, dirs, _ in os.walk(self.session.config.get_torrent_collecting_dir()): - for d in dirs: - dir_path = os.path.join(root, d) - rmtree(dir_path, ignore_errors=True) - break - - # update database version - self.db.write_version(24) - - def _upgrade_24_to_25(self): - self.status_update_func(u"Upgrading database from v%s to v%s..." % (24, 25)) - - # update database version (that one was easy :D) - self.db.write_version(25) - - def _upgrade_25_to_26(self): - self.status_update_func(u"Upgrading database from v%s to v%s..." % (25, 26)) - - # remove UserEventLog, TorrentSource, and TorrentCollecting tables - self.status_update_func(u"Removing unused tables...") - self.db.execute(u""" -DROP TABLE IF EXISTS UserEventLog; -DROP TABLE IF EXISTS TorrentSource; -DROP TABLE IF EXISTS TorrentCollecting; -""") - - # remove click_position, reranking_strategy, and progress from MyPreference - self.status_update_func(u"Updating MyPreference table...") - self.db.execute(u""" -CREATE TABLE _tmp_MyPreference ( - torrent_id integer PRIMARY KEY NOT NULL, - destination_path text NOT NULL, - creation_time integer NOT NULL -); - -INSERT INTO _tmp_MyPreference SELECT torrent_id, destination_path, creation_time FROM MyPreference; - -DROP TABLE MyPreference; -ALTER TABLE _tmp_MyPreference RENAME TO MyPreference; -""") - - # remove source_id and thumbnail columns from Torrent table - # replace torrent_file_name column with is_collected column - # change CollectedTorrent view - self.status_update_func(u"Updating Torrent table...") - self.db.execute(u""" -CREATE TABLE _tmp_Torrent ( - torrent_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - infohash text NOT NULL, - name text, - length integer, - creation_date integer, - num_files integer, - insert_time numeric, - secret integer, - relevance numeric DEFAULT 0, - category_id integer, - status_id integer DEFAULT 0, - num_seeders integer, - num_leechers integer, - comment text, - dispersy_id integer, - is_collected integer DEFAULT 0, - last_tracker_check integer DEFAULT 0, - tracker_check_retries integer DEFAULT 0, - next_tracker_check integer DEFAULT 0 -); - -UPDATE Torrent SET torrent_file_name = '1' WHERE torrent_file_name IS NOT NULL; -UPDATE Torrent SET torrent_file_name = '0' WHERE torrent_file_name IS NULL; - -INSERT INTO _tmp_Torrent -SELECT torrent_id, infohash, name, length, creation_date, num_files, insert_time, secret, relevance, category_id, -status_id, num_seeders, num_leechers, comment, dispersy_id, CAST(torrent_file_name AS INTEGER), -last_tracker_check, tracker_check_retries, next_tracker_check FROM Torrent; - -DROP VIEW IF EXISTS CollectedTorrent; -DROP TABLE Torrent; -ALTER TABLE _tmp_Torrent RENAME TO Torrent; - -CREATE VIEW CollectedTorrent AS SELECT * FROM Torrent WHERE is_collected == 1; -""") - - # update database version - self.db.write_version(26) - - def _upgrade_26_to_27(self): - self.status_update_func(u"Upgrading database from v%s to v%s..." % (26, 27)) - - # replace status_id and category_id in Torrent table with status and category - self.status_update_func(u"Updating Torrent table and removing unused tables...") - self.db.execute(u""" -CREATE TABLE _tmp_Torrent ( - torrent_id integer PRIMARY KEY AUTOINCREMENT NOT NULL, - infohash text NOT NULL, - name text, - length integer, - creation_date integer, - num_files integer, - insert_time numeric, - secret integer, - relevance numeric DEFAULT 0, - category text, - status text DEFAULT 'unknown', - num_seeders integer, - num_leechers integer, - comment text, - dispersy_id integer, - is_collected integer DEFAULT 0, - last_tracker_check integer DEFAULT 0, - tracker_check_retries integer DEFAULT 0, - next_tracker_check integer DEFAULT 0 -); - -INSERT INTO _tmp_Torrent -SELECT torrent_id, infohash, T.name, length, creation_date, num_files, insert_time, secret, relevance, C.name, TS.name, -num_seeders, num_leechers, comment, dispersy_id, is_collected, last_tracker_check, tracker_check_retries, -next_tracker_check -FROM Torrent AS T -LEFT JOIN Category AS C ON T.category_id == C.category_id -LEFT JOIN TorrentStatus AS TS ON T.status_id == TS.status_id; - -DROP VIEW IF EXISTS CollectedTorrent; -DROP TABLE Torrent; -ALTER TABLE _tmp_Torrent RENAME TO Torrent; -CREATE VIEW CollectedTorrent AS SELECT * FROM Torrent WHERE is_collected == 1; - -DROP TABLE Category; -DROP TABLE TorrentStatus; -""") - - # update database version - self.db.write_version(27) - - def _upgrade_27_to_28(self): - self.status_update_func(u"Upgrading database from v%s to v%s..." % (27, 28)) - - # remove old metadata stuff - self.status_update_func(u"Removing old metadata tables...") - self.db.execute(u""" -DROP TABLE IF EXISTS MetadataMessage; -DROP TABLE IF EXISTS MetadataData; -""") - # replace type_id with type in ChannelMetadata - self.db.execute(u""" -DROP TABLE IF EXISTS _ChannelMetaData_new; - -CREATE TABLE _ChannelMetaData_new ( - id integer PRIMARY KEY ASC, - dispersy_id integer NOT NULL, - channel_id integer NOT NULL, - peer_id integer, - type text NOT NULL, - value text NOT NULL, - prev_modification integer, - prev_global_time integer, - time_stamp integer NOT NULL, - inserted integer DEFAULT (strftime('%s','now')), - deleted_at integer, - UNIQUE (dispersy_id) -); - -INSERT INTO _ChannelMetaData_new -SELECT _ChannelMetaData.id, dispersy_id, channel_id, peer_id, MetadataTypes.name, value, prev_modification, prev_global_time, time_stamp, inserted, deleted_at -FROM _ChannelMetaData -LEFT JOIN MetadataTypes ON _ChannelMetaData.type_id == MetadataTypes.id; - -DROP VIEW IF EXISTS ChannelMetaData; -DROP TABLE IF EXISTS _ChannelMetaData; - -ALTER TABLE _ChannelMetaData_new RENAME TO _ChannelMetaData; -CREATE VIEW ChannelMetaData AS SELECT * FROM _ChannelMetaData WHERE deleted_at IS NULL; -DROP TABLE IF EXISTS MetaDataTypes; -""") - - # update database version - self.db.write_version(28) - - def _upgrade_28_to_29(self): - self.status_update_func(u"Upgrading FTS engine...") - - self.db.execute(u""" -DROP TABLE IF EXISTS FullTextIndex; -CREATE VIRTUAL TABLE FullTextIndex USING fts4(swarmname, filenames, fileextensions); - """) - self.db.commit_now() - - self.status_update_func(u"Reindexing torrents...") - self.reindex_torrents() - - # update database version - self.db.write_version(29) - - def reimport_torrents(self): - """Import all torrent files in the collected torrent dir, all the files already in the database will be ignored. - """ - self.status_update_func("Opening TorrentDBHandler...") - # TODO(emilon): That's a freakishly ugly hack. - torrent_db_handler = TorrentDBHandler(self.session) - torrent_db_handler.category = Category() - - # TODO(emilon): It would be nice to drop the corrupted torrent data from the store as a bonus. - self.status_update_func("Registering recovered torrents...") - try: - for infoshash_str, torrent_data in self.torrent_store.iteritems(): - self.status_update_func("> %s" % infoshash_str) - torrentdef = TorrentDef.load_from_memory(torrent_data) - if torrentdef.is_finalized(): - infohash = torrentdef.get_infohash() - if not torrent_db_handler.hasTorrent(infohash): - self.status_update_func(u"Registering recovered torrent: %s" % hexlify(infohash)) - torrent_db_handler._addTorrentToDB(torrentdef, extra_info={"filename": infoshash_str}) - finally: - torrent_db_handler.close() - self.db.commit_now() - return self.torrent_store.flush() - - def reindex_torrents(self): - """ - Reindex all torrents in the database. Required when upgrading to a newer FTS engine. - """ - results = self.db.fetchall("SELECT torrent_id, name FROM Torrent") - for torrent_result in results: - if torrent_result[1] is None: - continue - - swarmname = split_into_keywords(torrent_result[1]) - files_results = self.db.fetchall("SELECT path FROM TorrentFiles WHERE torrent_id = ?", (torrent_result[0],)) - filenames = "" - fileexts = "" - for file_result in files_results: - filename, ext = os.path.splitext(file_result[0]) - parts = split_into_keywords(filename) - filenames += " ".join(parts) + " " - fileexts += ext[1:] + " " - - self.db.execute_write(u"INSERT INTO FullTextIndex (rowid, swarmname, filenames, fileextensions)" - u" VALUES(?,?,?,?)", - (torrent_result[0], " ".join(swarmname), filenames[:-1], fileexts[:-1])) - - self.db.commit_now() diff --git a/Tribler/Core/Upgrade/pickle_converter.py b/Tribler/Core/Upgrade/pickle_converter.py deleted file mode 100644 index 47c32097384..00000000000 --- a/Tribler/Core/Upgrade/pickle_converter.py +++ /dev/null @@ -1,115 +0,0 @@ -from __future__ import absolute_import - -import glob -import os -import pickle -from six.moves.configparser import RawConfigParser - -from Tribler.Core.simpledefs import PERSISTENTSTATE_CURRENTVERSION - - -class PickleConverter(object): - """ - This class is responsible for converting old .pickle files used for configuration files to a newer ConfigObj format. - """ - - def __init__(self, session): - self.session = session - - def convert(self): - """ - Calling this method will convert all configuration files to the ConfigObj.state format. - """ - self.convert_session_config() - self.convert_main_config() - self.convert_download_checkpoints() - - def convert_session_config(self): - """ - Convert the sessionconfig.pickle file to triblerd.conf. Do nothing if we do not have a pickle file. - Remove the pickle file after we are done. - """ - old_filename = os.path.join(self.session.config.get_state_dir(), 'sessconfig.pickle') - - if not os.path.exists(old_filename): - return - - with open(old_filename, "rb") as old_file: - sessconfig = pickle.load(old_file) - - # Upgrade to .state config - new_config = self.session.config - for key, value in sessconfig.iteritems(): - if key == 'minport': - new_config.config['libtorrent']['port'] = value - if key in ['state_dir', 'install_dir', 'eckeypairfilename', 'megacache']: - new_config.config['general'][key] = value - if key == 'mainline_dht': - new_config.config['mainline_dht']['enabled'] = value - if key == 'mainline_dht_port': - new_config.config['mainline_dht']['port'] = value - if key == 'torrent_checking': - new_config.config['torrent_checking']['enabled'] = value - if key in ['torrent_collecting', 'torrent_collecting_max_torrents', 'torrent_collecting_dir']: - new_config.config['torrent_collecting']['enabled' if key == 'torrent_collecting' else key] = value - if key in ['libtorrent', 'lt_proxytype', 'lt_proxyserver', 'lt_proxyauth']: - new_config.config['libtorrent']['enabled' if key == 'libtorrent' else key] = value - if key in ['dispersy_port', 'dispersy']: - new_config.config['dispersy']['enabled' if key == 'dispersy' else 'port'] = value - - # Save the new file, remove the old one - new_config.write() - os.remove(old_filename) - - def convert_main_config(self): - """ - Convert the abc.conf, user_download_choice.pickle, gui_settings and recent download history files - to triblerd.conf. - """ - new_config = self.session.config - - # Convert user_download_choice.pickle - udcfilename = os.path.join(self.session.config.get_state_dir(), 'user_download_choice.pickle') - if os.path.exists(udcfilename): - with open(udcfilename, "r") as udc_file: - choices = pickle.Unpickler(udc_file).load() - choices = dict([(k.encode('hex'), v) for k, v in choices["download_state"].iteritems()]) - new_config.config['user_download_states'] = choices - new_config.write() - os.remove(udcfilename) - - def convert_download_checkpoints(self): - """ - Convert all pickle download checkpoints to .state files. - """ - checkpoint_dir = self.session.get_downloads_pstate_dir() - - filelist = os.listdir(checkpoint_dir) - if not any([filename.endswith('.pickle') for filename in filelist]): - return - - if os.path.exists(checkpoint_dir): - for old_filename in glob.glob(os.path.join(checkpoint_dir, '*.pickle')): - try: - with open(old_filename, "rb") as old_file: - old_checkpoint = pickle.load(old_file) - except (EOFError, KeyError): - # Pickle file appears to be corrupted, remove it and continue - os.remove(old_filename) - continue - - new_checkpoint = RawConfigParser() - new_checkpoint.add_section('downloadconfig') - new_checkpoint.add_section('state') - for key, value in old_checkpoint['dlconfig'].iteritems(): - if key in ['saveas', 'max_upload_rate', 'max_download_rate', 'super_seeder', 'mode', - 'selected_files', 'correctedfilename']: - new_checkpoint.set('downloadconfig', key, value) - new_checkpoint.set('state', 'version', PERSISTENTSTATE_CURRENTVERSION) - new_checkpoint.set('state', 'engineresumedata', old_checkpoint['engineresumedata']) - new_checkpoint.set('state', 'dlstate', old_checkpoint['dlstate']) - new_checkpoint.set('state', 'metainfo', old_checkpoint['metainfo']) - with open(old_filename.replace('.pickle', '.state'), "wb") as new_file: - new_checkpoint.write(new_file) - - os.remove(old_filename) diff --git a/Tribler/Core/Upgrade/torrent_upgrade64.py b/Tribler/Core/Upgrade/torrent_upgrade64.py deleted file mode 100644 index e6f55871473..00000000000 --- a/Tribler/Core/Upgrade/torrent_upgrade64.py +++ /dev/null @@ -1,224 +0,0 @@ -""" -Migration scripts for migrating to 6.4 - -Author(s): Elric Milon -""" -from __future__ import absolute_import - -import logging -import os -from binascii import hexlify -from shutil import rmtree, move -from sqlite3 import Connection - -from six.moves import xrange -from Tribler.Core.TorrentDef import TorrentDef - - -class TorrentMigrator64(object): - - """ - Migration tool for upgrading the collected torrent files/thumbnails on disk - structure from Tribler version 6.3 to 6.4. - """ - - def __init__(self, torrent_collecting_dir, state_dir, status_update_func=None): - self._logger = logging.getLogger(self.__class__.__name__) - self.status_update_func = status_update_func if status_update_func else lambda _: None - - self.torrent_collecting_dir = torrent_collecting_dir - self.state_dir = state_dir - - self.swift_files_deleted = 0 - self.torrent_files_dropped = 0 - self.torrent_files_migrated = 0 - self.total_torrent_files_processed = 0 - - self.total_swift_file_count = 0 - self.total_torrent_file_count = 0 - - self.total_file_count = 0 - self.processed_file_count = 0 - - # an empty file, if it doesn't exist then we need still need to migrate the torrent collecting directory - self.tmp_migration_tcd_file = os.path.join(self.state_dir, u".tmp_migration_v64_tcd") - - # we put every migrated torrent file in a temporary directory - self.tmp_migration_dir = os.path.abspath(os.path.join(self.state_dir, u".tmp_migration_v64")) - - def start_migrate(self): - """ - Starts migrating from Tribler 6.3 to 6.4. - """ - # remove some previous left files - useless_files = [u"upgradingdb.txt", u"upgradingdb2.txt", u"upgradingdb3.txt", u"upgradingdb4.txt"] - for i in xrange(len(useless_files)): - useless_tmp_file = os.path.join(self.state_dir, useless_files[i]) - if os.path.exists(useless_tmp_file): - os.unlink(useless_tmp_file) - - self._migrate_torrent_collecting_dir() - - # remove the temporary file if exists - if os.path.exists(self.tmp_migration_tcd_file): - os.unlink(self.tmp_migration_tcd_file) - - def _migrate_torrent_collecting_dir(self): - """ - Migrates the torrent collecting directory. - """ - if os.path.exists(self.tmp_migration_tcd_file): - return - - # check and create the temporary migration directory if necessary - if not os.path.exists(self.tmp_migration_dir): - try: - os.mkdir(self.tmp_migration_dir) - except OSError as e: - msg = u"Failed to create temporary torrent collecting migration directory %s: %s" %\ - (self.tmp_migration_dir, e) - raise OSError(msg) - elif not os.path.isdir(self.tmp_migration_dir): - msg = u"The temporary torrent collecting migration path is not a directory: %s" % self.tmp_migration_dir - raise RuntimeError(msg) - - if not os.path.isdir(self.torrent_collecting_dir): - raise RuntimeError(u"The torrent collecting directory doesn't exist: %s", self.torrent_collecting_dir) - - self._delete_swift_reseeds() - - # get total file numbers and then start cleaning up - self._get_total_file_count() - self._delete_swift_files() - self._rename_torrent_files() - - # delete all directories in the torrent collecting directory, we don't migrate thumbnails - self._delete_all_directories() - - # replace the old directory with the new one - rmtree(self.torrent_collecting_dir) - move(self.tmp_migration_dir, self.torrent_collecting_dir) - - # create the empty file to indicate that we have finished the torrent collecting directory migration - open(self.tmp_migration_tcd_file, "wb").close() - - def _get_total_file_count(self): - """ - Walks through the torrent collecting directory and gets the total number of file. - """ - self.status_update_func( - u"Scanning torrent directory. This may take a while if you have a big torrent collection...") - for root, _, files in os.walk(self.torrent_collecting_dir): - for name in files: - if name.endswith(u".mbinmap") or name.endswith(u".mhash") or name.startswith(u"tmp_"): - self.total_swift_file_count += 1 - else: - self.total_torrent_file_count += 1 - self.total_file_count += 1 - self.status_update_func(u"Getting file count: %s..." % self.total_file_count) - # We don't want to walk through the child directories - break - - def _delete_swift_reseeds(self): - """ - Deletes the reseeds dir, not used anymore. - """ - reseeds_path = os.path.join(self.torrent_collecting_dir, u"swift_reseeds") - if os.path.exists(reseeds_path): - if not os.path.isdir(reseeds_path): - raise RuntimeError(u"The swift_reseeds path is not a directory: %s", reseeds_path) - rmtree(reseeds_path) - self.swift_files_deleted += 1 - - def _delete_swift_files(self): - """ - Deletes all partial swift downloads, also clean up obsolete .mhash and .mbinmap files. - """ - def update_status(): - progress = 1.0 - if self.total_swift_file_count > 0: - progress = float(self.swift_files_deleted) / self.total_swift_file_count - progress *= 100 - self.status_update_func(u"Deleting swift files %.1f%%..." % progress) - - for root, _, files in os.walk(self.torrent_collecting_dir): - for name in files: - if name.endswith(u".mbinmap") or name.endswith(u".mhash") or name.startswith(u"tmp_"): - os.unlink(os.path.join(root, name)) - # update progress - self.swift_files_deleted += 1 - self.processed_file_count += 1 - update_status() - - # We don't want to walk through the child directories - break - - def _rename_torrent_files(self): - """ - Renames all the torrent files to INFOHASH.torrent and delete unparseable ones. - """ - def update_status(): - progress = 1.0 - if self.total_torrent_file_count > 0: - progress = float(self.total_torrent_files_processed) / self.total_torrent_file_count - progress *= 100 - self.status_update_func(u"Migrating torrent files %.2f%%..." % progress) - - for root, _, files in os.walk(self.torrent_collecting_dir): - for name in files: - file_path = os.path.join(root, name) - try: - tdef = TorrentDef.load(file_path) - move(file_path, os.path.join(self.tmp_migration_dir, hexlify(tdef.infohash) + u".torrent")) - self.torrent_files_migrated += 1 - except Exception as e: - self._logger.error(u"dropping corrupted torrent file %s: %s", file_path, str(e)) - os.unlink(file_path) - self.torrent_files_dropped += 1 - self.total_torrent_files_processed += 1 - update_status() - - # We don't want to walk through the child directories - break - - def _delete_all_directories(self): - """ - Deletes all directories in the torrent collecting directory. - """ - self.status_update_func(u"Checking all directories in torrent collecting directory...") - for root, dirs, files in os.walk(self.torrent_collecting_dir): - for d in dirs: - dir_path = os.path.join(root, d) - rmtree(dir_path, ignore_errors=True) - - def _update_dispersy(self): - """ - Cleans up all SearchCommunity and MetadataCommunity stuff in dispersy database. - """ - db_path = os.path.join(self.state_dir, u"sqlite", u"dispersy.db") - if not os.path.isfile(db_path): - return - - communities_to_delete = (u"SearchCommunity", u"MetadataCommunity") - - connection = Connection(db_path) - cursor = connection.cursor() - - data_updated = False - for community in communities_to_delete: - try: - result = list(cursor.execute(u"SELECT id FROM community WHERE classification == ?", (community,))) - - for community_id, in result: - self._logger.info(u"deleting all data for community %s...", community_id) - cursor.execute(u"DELETE FROM community WHERE id == ?", (community_id,)) - cursor.execute(u"DELETE FROM meta_message WHERE community == ?", (community_id,)) - cursor.execute(u"DELETE FROM sync WHERE community == ?", (community_id,)) - data_updated = True - except StopIteration: - continue - - if data_updated: - connection.commit() - cursor.close() - connection.close() diff --git a/Tribler/Core/Upgrade/torrent_upgrade65.py b/Tribler/Core/Upgrade/torrent_upgrade65.py deleted file mode 100644 index 473c329289b..00000000000 --- a/Tribler/Core/Upgrade/torrent_upgrade65.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -Migration scripts for migrating to 6.5 - -Author(s): Elric Milon -""" -import os -from binascii import hexlify -from shutil import rmtree - -from Tribler.Core.TorrentDef import TorrentDef -from .torrent_upgrade64 import TorrentMigrator64 - - -class TorrentMigrator65(TorrentMigrator64): - - def __init__(self, torrent_collecting_dir, state_dir, torrent_store, status_update_func=None): - super(TorrentMigrator65, self).__init__(torrent_collecting_dir, state_dir, status_update_func) - self.torrent_store = torrent_store - - def _migrate_torrent_collecting_dir(self): - """ - Migrates the torrent collecting directory. - """ - if self.torrent_collecting_dir is None or not os.path.isdir(self.torrent_collecting_dir): - self._logger.info(u"torrent collecting directory not found, skip: %s", self.torrent_collecting_dir) - return - - self._delete_swift_reseeds() - - # get total file numbers and then start cleaning up - self._get_total_file_count() - self._delete_swift_files() - self._ingest_torrent_files() - - # delete all directories in the torrent collecting directory, we don't migrate thumbnails - self._delete_all_directories() - - # replace the old directory with the new one - rmtree(self.torrent_collecting_dir) - - def _ingest_torrent_files(self): - """ - Renames all the torrent files to INFOHASH.torrent and delete unparseable ones. - """ - def update_status(): - progress = 1.0 - if self.total_torrent_file_count > 0: - progress = float(self.total_torrent_files_processed) / self.total_torrent_file_count - progress *= 100 - self.status_update_func(u"Ingesting torrent files %.1f%% (%d/%d)..." - % (progress, self.torrent_files_migrated, - self.torrent_files_dropped)) - - self.status_update_func("Ingesting torrent files...") - for root, _, files in os.walk(self.torrent_collecting_dir): - for name in files: - file_path = os.path.join(root, name) - try: - tdef = TorrentDef.load(file_path) - # TODO(emilon): This should be moved out of the try block so - # an error there doesn't wipe the whole torrent collection. - with open(file_path, 'rb') as torrent_file: - self.torrent_store[hexlify(tdef.infohash)] = torrent_file.read() - # self.torrent_store[hexlify(tdef.infohash)] = tdef.encode() - self.torrent_files_migrated += 1 - except Exception as e: - self._logger.error(u"dropping corrupted torrent file %s: %s", file_path, str(e)) - self.torrent_files_dropped += 1 - os.unlink(file_path) - self.total_torrent_files_processed += 1 - if not self.total_torrent_files_processed % 2000: - self.torrent_store.flush() - update_status() - - # We don't want to walk through the child directories - break - self.status_update_func("All torrent files processed.") diff --git a/Tribler/Core/Upgrade/upgrade.py b/Tribler/Core/Upgrade/upgrade.py index d9df37098f9..d28dbc798c2 100644 --- a/Tribler/Core/Upgrade/upgrade.py +++ b/Tribler/Core/Upgrade/upgrade.py @@ -1,30 +1,16 @@ +from __future__ import absolute_import + import logging -import os -import shutil -from twisted.internet.defer import inlineCallbacks -from Tribler.Core.CacheDB.db_versions import LATEST_DB_VERSION, LOWEST_SUPPORTED_DB_VERSION from Tribler.Core.Upgrade.config_converter import convert_config_to_tribler71 -from Tribler.Core.Upgrade.db_upgrader import DBUpgrader -from Tribler.Core.Upgrade.pickle_converter import PickleConverter -from Tribler.Core.Upgrade.torrent_upgrade65 import TorrentMigrator65 -from Tribler.Core.simpledefs import NTFY_UPGRADER, NTFY_FINISHED, NTFY_STARTED, NTFY_UPGRADER_TICK - - -# Database versions: -# *earlier versions are no longer supported -# 17 is used by Tribler 5.9.x - 6.0 -# 18 is used by Tribler 6.1.x - 6.2.0 -# 22 is used by Tribler 6.3.x -# 23 is used by Tribler 6.4 +from Tribler.Core.simpledefs import NTFY_FINISHED, NTFY_STARTED, NTFY_UPGRADER, NTFY_UPGRADER_TICK class TriblerUpgrader(object): - def __init__(self, session, db): + def __init__(self, session): self._logger = logging.getLogger(self.__class__.__name__) self.session = session - self.db = db self.notified = False self.is_done = False @@ -39,29 +25,19 @@ def run(self): Note that by default, upgrading is enabled in the config. It is then disabled after upgrading to Tribler 7. """ - self.current_status = u"Checking Tribler version..." - failed, has_to_upgrade = self.check_should_upgrade_database() - if has_to_upgrade and not failed: - self.notify_starting() - self.upgrade_database_to_current_version() - - # Convert old (pre 6.3 Tribler) pickle files to the newer .state format - pickle_converter = PickleConverter(self.session) - pickle_converter.convert() + self.notify_starting() - if self.failed: - self.notify_starting() - self.stash_database() + self.upgrade_config_to_71() - self.upgrade_to_tribler7() + self.notify_done() def update_status(self, status_text): self.session.notifier.notify(NTFY_UPGRADER_TICK, NTFY_STARTED, None, status_text) self.current_status = status_text - def upgrade_to_tribler7(self): + def upgrade_config_to_71(self): """ - This method performs actions necessary to upgrade to Tribler 7. + This method performs actions necessary to upgrade the configuration files to Tribler 7.1. """ self.session.config = convert_config_to_tribler71(self.session.config) self.session.config.write() @@ -81,64 +57,3 @@ def notify_done(self): Broadcast a notification (event) that the upgrader is done. """ self.session.notifier.notify(NTFY_UPGRADER, NTFY_FINISHED, None) - - def check_should_upgrade_database(self): - self.failed = True - should_upgrade = False - if self.db.version > LATEST_DB_VERSION: - msg = u"The on-disk tribler database is newer than your tribler version. Your database will be backed up." - self.current_status = msg - self._logger.info(msg) - elif self.db.version < LOWEST_SUPPORTED_DB_VERSION: - msg = u"Database is too old %s < %s" % (self.db.version, LOWEST_SUPPORTED_DB_VERSION) - self.current_status = msg - elif self.db.version == LATEST_DB_VERSION: - self._logger.info(u"tribler is in the latest version, no need to upgrade") - self.failed = False - self.is_done = True - self.notify_done() - else: - should_upgrade = True - self.failed = False - - return (self.failed, should_upgrade) - - @inlineCallbacks - def upgrade_database_to_current_version(self): - """ Checks the database version and upgrade if it is not the latest version. - """ - try: - from Tribler.Core.leveldbstore import LevelDbStore - torrent_store = LevelDbStore(self.session.config.get_torrent_store_dir()) - torrent_migrator = TorrentMigrator65( - self.session.config.get_torrent_collecting_dir(), self.session.config.get_state_dir(), - torrent_store=torrent_store, status_update_func=self.update_status) - yield torrent_migrator.start_migrate() - - db_migrator = DBUpgrader( - self.session, self.db, torrent_store=torrent_store, status_update_func=self.update_status) - yield db_migrator.start_migrate() - - # Import all the torrent files not in the database, we do this in - # case we have some unhandled torrent files left due to - # bugs/crashes, etc. - self.update_status("Recovering unregistered torrents...") - yield db_migrator.reimport_torrents() - - yield torrent_store.close() - del torrent_store - - self.failed = False - self.is_done = True - except Exception as e: - self._logger.exception(u"failed to upgrade: %s", e) - - def stash_database(self): - self.db.close() - old_dir = os.path.dirname(self.db.sqlite_db_path) - new_dir = u'%s_backup_%d' % (old_dir, LATEST_DB_VERSION) - shutil.move(old_dir, new_dir) - os.makedirs(old_dir) - self.db.initialize() - self.is_done = True - self.notify_done() diff --git a/Tribler/Core/__init__.py b/Tribler/Core/__init__.py index 0aba50dd3ff..3c29597bc9b 100644 --- a/Tribler/Core/__init__.py +++ b/Tribler/Core/__init__.py @@ -3,58 +3,3 @@ Author(s): Arno Bakker """ -import logging -from threading import RLock - -try: - long # pylint: disable=long-builtin -except NameError: - long = int # pylint: disable=redefined-builtin - -logger = logging.getLogger(__name__) - - -def warnIfDispersyThread(func): - """ - We'd rather not be on the Dispersy thread, but if we are lets continue and - hope for the best. This was introduced after the database thread stuffs - caused deadlocks. We weren't sure we got all of them, so we implemented - warnings instead of errors because they probably wouldn't cause a deadlock, - but if they did we would have the warning somewhere. - - Niels dixit. - """ - def invoke_func(*args, **kwargs): - from twisted.python.threadable import isInIOThread - from traceback import print_stack - - if isInIOThread(): - import inspect - caller = inspect.stack()[1] - callerstr = "%s %s:%s" % (caller[3], caller[1], caller[2]) - - from time import time - logger.error("%d CANNOT BE ON DISPERSYTHREAD %s %s:%s called by %s", long(time()), - func.__name__, func.func_code.co_filename, func.func_code.co_firstlineno, callerstr) - print_stack() - - return func(*args, **kwargs) - - invoke_func.__name__ = func.__name__ - return invoke_func - - -class NoDispersyRLock(): - - def __init__(self): - self.lock = RLock() - self.__enter__ = self.lock.__enter__ - self.__exit__ = self.lock.__exit__ - - @warnIfDispersyThread - def acquire(self, blocking=1): - return self.lock.acquire(blocking) - - @warnIfDispersyThread - def release(self): - return self.lock.release() diff --git a/Tribler/Core/leveldbstore.py b/Tribler/Core/leveldbstore.py deleted file mode 100644 index 38b7a4637e6..00000000000 --- a/Tribler/Core/leveldbstore.py +++ /dev/null @@ -1,161 +0,0 @@ -""" -LevelDBStore. - -Author(s): Elric Milon -""" -from __future__ import absolute_import - -import logging -import os -import sys -from collections import MutableMapping -from itertools import chain -from shutil import rmtree - -from twisted.internet import reactor -from twisted.internet.task import LoopingCall - -from Tribler.Core.exceptions import LevelDBKeyDeletionException -from Tribler.pyipv8.ipv8.taskmanager import TaskManager - - -def get_write_batch_leveldb(self, _): - from leveldb import WriteBatch - return WriteBatch() - - -def get_write_batch_plyvel(self, db): - from Tribler.Core.plyveladapter import WriteBatch - return WriteBatch(db) - -try: - from leveldb import LevelDB, LevelDBError - - use_leveldb = True - get_write_batch = get_write_batch_leveldb - -except ImportError: - from Tribler.Core.plyveladapter import LevelDB # pylint: disable=ungrouped-imports - - use_leveldb = False - get_write_batch = get_write_batch_plyvel - - -WRITEBACK_PERIOD = 120 - -# TODO(emilon): Make sure the caching makes an actual difference in IO and kill -# it if it doesn't as it complicates the code. - - -class LevelDbStore(MutableMapping, TaskManager): - _reactor = reactor - _leveldb = LevelDB - _writebatch = get_write_batch - - def __init__(self, store_dir): - super(LevelDbStore, self).__init__() - - self._store_dir = store_dir - self._pending_torrents = {} - self._logger = logging.getLogger(self.__class__.__name__) - # This is done to work around LevelDB's inability to deal with non-ascii paths on windows. - try: - db_path = store_dir.decode('windows-1252') if sys.platform == "win32" else store_dir - self._db = self._leveldb(db_path) - except ValueError: - # This can happen on Windows when the state dir and Tribler installation are on different disks. - # In this case, hope for the best by using the full path. - self._db = self._leveldb(store_dir) - except Exception as exc: - # We cannot simply catch LevelDBError since that class might not be available on some systems. - if use_leveldb and isinstance(exc, LevelDBError): - # The database might be corrupt, start with a fresh one - self._logger.error("Corrupt LevelDB store detected; recreating database") - rmtree(self._store_dir) - os.makedirs(self._store_dir) - self._db = self._leveldb(os.path.relpath(store_dir, os.getcwdu())) - else: # If something else goes wrong, we throw the exception again - raise - - self._writeback_lc = self.register_task("flush cache ", LoopingCall(self.flush)) - self._writeback_lc.clock = self._reactor - self._writeback_lc.start(WRITEBACK_PERIOD) - - def get_db(self): - return self._db - - def __getitem__(self, key): - try: - return self._pending_torrents[key] - except KeyError: - return self._db.Get(key) - - def __setitem__(self, key, value): - self._pending_torrents[key] = value - - def __delitem__(self, key): - if key in self._pending_torrents: - self._pending_torrents.pop(key) - try: - self._db.Delete(key) - except Exception: - raise LevelDBKeyDeletionException(msg="Failed to delete key: %s" % key) - - def __iter__(self): - for k in self._pending_torrents.iterkeys(): - yield k - for k, _ in self._db.RangeIter(): - yield k - - def __contains__(self, key): - if key in self._pending_torrents: - return True - try: - self.__getitem__(key) - return True - except KeyError: - pass - - return False - - def __len__(self): - return len(self._pending_torrents) + len(list(self.keys())) - - def keys(self): - return [k for k, _ in self._db.RangeIter()] - - def iteritems(self): - return chain(self._pending_torrents, self._db.RangeIter()) - - def put(self, k, v): - self.__setitem__(k, v) - - def rangescan(self, start=None, end=None): - if start is None and end is None: - return self._db.RangeIter() - elif end is None: - return self._db.RangeIter(key_from=start) - else: - return self._db.RangeIter(key_from=start, key_to=end) - - def flush(self, retry=3, write_batch=None): - if not write_batch and self._pending_torrents: - write_batch = self._writebatch(self._db) - for k, v in self._pending_torrents.iteritems(): - write_batch.Put(k, v) - self._pending_torrents.clear() - - if write_batch: - if not retry: - self._logger.error("Failed to flush LevelDB cache. Max retry done.") - return - try: - self._db.Write(write_batch) - except Exception as ex: - self._logger.error("Failed to flush LevelDB cache. Will retry %s times. Error:%s", retry-1, ex) - self.flush(retry=retry-1, write_batch=write_batch) - - def close(self): - self.shutdown_task_manager() - self.flush() - self._db = None diff --git a/Tribler/Core/permid.py b/Tribler/Core/permid.py index 5942e4a3e7f..d56f22db7f7 100644 --- a/Tribler/Core/permid.py +++ b/Tribler/Core/permid.py @@ -4,55 +4,11 @@ Author(s): Arno Bakker """ import logging -import os -from M2Crypto import Rand, EC, BIO from Tribler.pyipv8.ipv8.keyvault.private.libnaclkey import LibNaCLSK logger = logging.getLogger(__name__) -# Internal constants -KEYPAIR_ECC_CURVE = EC.NID_sect233k1 -NUM_RANDOM_BITS = 1024 * 8 # bits - -# Exported functions - -# a workaround is needed for Tribler to function on Windows 64 bit -# instead of invoking EC.load_key(filename), we should use the M2Crypto.BIO buffer -# see http://stackoverflow.com/questions/33720087/error-when-importing-m2crypto-in-python-on-windows-x64 - -def init(): - Rand.rand_seed(os.urandom(NUM_RANDOM_BITS / 8)) - - -def generate_keypair(): - ec_keypair = EC.gen_params(KEYPAIR_ECC_CURVE) - ec_keypair.gen_key() - return ec_keypair - - -def read_keypair(keypairfilename): - membuf = BIO.MemoryBuffer(open(keypairfilename, 'rb').read()) - key = EC.load_key_bio(membuf) - membuf.close() - return key - - -def save_keypair(keypair, keypairfilename): - membuf = BIO.MemoryBuffer() - keypair.save_key_bio(membuf, None) - with open(keypairfilename, 'w') as file: - file.write(membuf.read()) - membuf.close() - - -def save_pub_key(keypair, pubkeyfilename): - membuf = BIO.MemoryBuffer() - keypair.save_pub_key_bio(membuf) - with open(pubkeyfilename, 'w') as file: - file.write(membuf.read()) - membuf.close() - def generate_keypair_trustchain(): return LibNaCLSK() diff --git a/Tribler/Core/plyveladapter.py b/Tribler/Core/plyveladapter.py deleted file mode 100644 index dbbe4cd9dcc..00000000000 --- a/Tribler/Core/plyveladapter.py +++ /dev/null @@ -1,42 +0,0 @@ -import plyvel - - -class LevelDB(object): - - def __init__(self, store_dir, create_if_missing=True): - self._db = plyvel.DB(store_dir, create_if_missing=create_if_missing) - - def Get(self, key, verify_checksums=False, fill_cache=True): - val = self._db.get(key, verify_checksums=verify_checksums, fill_cache=fill_cache) - if val: - return val - raise KeyError('No value for key {key}'.format(key=key)) - - def Put(self, key, value, sync=False): - self._db.put(key, value, sync=sync) - - def Delete(self, key, sync=False): - return self._db.delete(key, sync=sync) - - def RangeIter(self, key_from=None, key_to=None, include_value=True, verify_checksums=False, fill_cache=True): - return self._db.iterator(start=key_from, stop=key_to, include_value=include_value, - verify_checksums=verify_checksums, fill_cache=fill_cache) - - def Write(self, write_batch, sync=False): - write_batch._batch.write() - - def GetStats(self): - pass # No such method in plyvel - - -class WriteBatch(object): - - def __init__(self, db): - # Using transaction and sync in Windows to prevent CorruptionError - self._batch = db._db.write_batch(transaction=True, sync=True) - - def Put(self, key, value): - self._batch.put(key, value) - - def Delete(self, key): - self._batch.delete(key) diff --git a/Tribler/Core/simpledefs.py b/Tribler/Core/simpledefs.py index 23986be499c..798bac977a5 100644 --- a/Tribler/Core/simpledefs.py +++ b/Tribler/Core/simpledefs.py @@ -35,6 +35,7 @@ STATEDIR_DLPSTATE_DIR = u'dlcheckpoints' STATEDIR_WALLET_DIR = u'wallet' STATEDIR_CHANNELS_DIR = u'channels' +STATEDIR_DB_DIR = u"sqlite" # For observer/callback mechanism, see Session.add_observer() # subjects @@ -145,17 +146,12 @@ STATE_EXCEPTION = "EXCEPTION" STATE_SHUTDOWN = "SHUTDOWN" -STATE_OPEN_DB = 'Opening database...' STATE_START_API = 'Starting HTTP API...' STATE_UPGRADING_READABLE = 'Upgrading Tribler...' STATE_LOAD_CHECKPOINTS = 'Loading download checkpoints...' -STATE_STARTING_DISPERSY = 'Starting Dispersy...' -STATE_LOADING_COMMUNITIES = 'Loading communities...' -STATE_INITIALIZE_CHANNEL_MGR = 'Initializing channel manager...' STATE_START_MAINLINE_DHT = 'Starting mainline DHT...' STATE_START_LIBTORRENT = 'Starting libtorrent...' STATE_START_TORRENT_CHECKER = 'Starting torrent checker...' -STATE_START_REMOTE_TORRENT_HANDLER = 'Starting remote torrent handler...' STATE_START_API_ENDPOINTS = 'Starting API endpoints...' STATE_START_WATCH_FOLDER = 'Starting watch folder...' STATE_START_CREDIT_MINING = 'Starting credit mining...' diff --git a/Tribler/Core/statistics.py b/Tribler/Core/statistics.py index 2b989331bd0..d14ae84f2d0 100644 --- a/Tribler/Core/statistics.py +++ b/Tribler/Core/statistics.py @@ -3,11 +3,7 @@ import os import time -from six import text_type - -from Tribler.Core.CacheDB.sqlitecachedb import DB_FILE_RELATIVE_PATH from Tribler.Core.exceptions import OperationNotEnabledByConfigurationException -from Tribler.Core.simpledefs import NTFY_TORRENTS, NTFY_CHANNELCAST from Tribler.pyipv8.ipv8.messaging.interfaces.statistics_endpoint import StatisticsEndpoint DATA_NONE = u"None" @@ -26,105 +22,11 @@ def get_tribler_statistics(self): """ Return a dictionary with some general Tribler statistics. """ - torrent_db_handler = self.session.open_dbhandler(NTFY_TORRENTS) - channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - - torrent_stats = torrent_db_handler.getTorrentsStats() - torrent_total_size = 0 if torrent_stats[1] is None else torrent_stats[1] - - stats_dict = {"torrents": {"num_collected": torrent_stats[0], "total_size": torrent_total_size, - "num_files": torrent_stats[2]}, - - "num_channels": channel_db_handler.getNrChannels(), - "database_size": os.path.getsize( - os.path.join(self.session.config.get_state_dir(), DB_FILE_RELATIVE_PATH))} - - if self.session.lm.rtorrent_handler: - torrent_queue_stats = self.session.lm.rtorrent_handler.get_queue_stats() - torrent_queue_size_stats = self.session.lm.rtorrent_handler.get_queue_size_stats() - torrent_queue_bandwidth_stats = self.session.lm.rtorrent_handler.get_bandwidth_stats() - - stats_dict["torrent_queue_stats"] = torrent_queue_stats - stats_dict["torrent_queue_size_stats"] = torrent_queue_size_stats - stats_dict["torrent_queue_bandwidth_stats"] = torrent_queue_bandwidth_stats + db_size = os.path.getsize(self.session.lm.mds.db_filename) if self.session.lm.mds else 0 + stats_dict = {"db_size": db_size} return stats_dict - def get_dispersy_statistics(self): - """ - Return a dictionary with some general Dispersy statistics. - """ - dispersy = self.session.get_dispersy_instance() - dispersy.statistics.update() - stats = dispersy.statistics - return { - "wan_address": "%s:%d" % stats.wan_address, - "lan_address": "%s:%d" % stats.lan_address, - "connection": text_type(stats.connection_type), - "runtime": stats.timestamp - stats.start, - "total_downloaded": stats.total_down, - "total_uploaded": stats.total_up, - "packets_sent": stats.total_send, - "packets_received": stats.total_received, - "packets_success": stats.msg_statistics.success_count, - "packets_dropped": stats.msg_statistics.drop_count, - "packets_delayed_sent": stats.msg_statistics.delay_send_count, - "packets_delayed_received": stats.msg_statistics.delay_received_count, - "packets_delayed_success": stats.msg_statistics.delay_success_count, - "packets_delayed_timeout": stats.msg_statistics.delay_timeout_count, - "total_walk_attempts": stats.walk_attempt_count, - "total_walk_success": stats.walk_success_count, - "sync_messages_created": stats.msg_statistics.created_count, - "bloom_new": sum(c.sync_bloom_new for c in stats.communities), - "bloom_reused": sum(c.sync_bloom_reuse for c in stats.communities), - "bloom_skipped": sum(c.sync_bloom_skip for c in stats.communities), - } - - def get_dispersy_community_statistics(self): - """ - Return a dictionary with general statistics of the active Dispersy communities. - """ - communities_stats = [] - try: - dispersy = self.session.get_dispersy_instance() - dispersy.statistics.update() - except OperationNotEnabledByConfigurationException: - return [] - - for community in dispersy.statistics.communities: - if community.dispersy_enable_candidate_walker or community.dispersy_enable_candidate_walker_responses or \ - community.candidates: - candidate_count = "%s" % len(community.candidates) - else: - candidate_count = "-" - - communities_stats.append({ - "identifier": community.hex_cid, - "member": community.hex_mid, - "classification": community.classification, - "global_time": community.global_time, - "median_global_time": community.acceptable_global_time - - community.dispersy_acceptable_global_time_range, - "acceptable_global_time_range": community.dispersy_acceptable_global_time_range, - "walk_attempts": community.msg_statistics.walk_attempt_count, - "walk_success": community.msg_statistics.walk_success_count, - "sync_bloom_created": community.sync_bloom_new, - "sync_bloom_reused": community.sync_bloom_reuse, - "sync_bloom_skipped": community.sync_bloom_skip, - "sync_messages_created": community.msg_statistics.created_count, - "packets_sent": community.msg_statistics.outgoing_count, - "packets_received": community.msg_statistics.total_received_count, - "packets_success": community.msg_statistics.success_count, - "packets_dropped": community.msg_statistics.drop_count, - "packets_delayed_sent": community.msg_statistics.delay_send_count, - "packets_delayed_received": community.msg_statistics.delay_received_count, - "packets_delayed_success": community.msg_statistics.delay_success_count, - "packets_delayed_timeout": community.msg_statistics.delay_timeout_count, - "candidates": candidate_count - }) - - return communities_stats - def get_ipv8_statistics(self): """ Return generic IPv8 statistics. diff --git a/Tribler/Test/API/test_download.py b/Tribler/Test/API/test_download.py index 5ac62a0728e..d265ab067a6 100644 --- a/Tribler/Test/API/test_download.py +++ b/Tribler/Test/API/test_download.py @@ -1,16 +1,18 @@ +from __future__ import absolute_import + import logging import os import shutil from binascii import hexlify from unittest import skip -from Tribler.Test.tools import trial_timeout from twisted.internet.defer import Deferred from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.simpledefs import dlstatus_strings, DLSTATUS_DOWNLOADING -from Tribler.Test.common import UBUNTU_1504_INFOHASH, TORRENT_UBUNTU_FILE +from Tribler.Test.common import TORRENT_UBUNTU_FILE, UBUNTU_1504_INFOHASH +from Tribler.Core.simpledefs import DLSTATUS_DOWNLOADING, dlstatus_strings from Tribler.Test.test_as_server import TestAsServer +from Tribler.Test.tools import trial_timeout class TestDownload(TestAsServer): @@ -28,7 +30,6 @@ def setUpPreSession(self): super(TestDownload, self).setUpPreSession() self.config.set_libtorrent_enabled(True) - self.config.set_dispersy_enabled(False) self.config.set_libtorrent_max_conn_download(2) def on_download(self, download): diff --git a/Tribler/Test/Community/AbstractTestCommunity.py b/Tribler/Test/Community/AbstractTestCommunity.py deleted file mode 100644 index eb03403b0f7..00000000000 --- a/Tribler/Test/Community/AbstractTestCommunity.py +++ /dev/null @@ -1,28 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Test.test_as_server import AbstractServer -from Tribler.dispersy.dispersy import Dispersy -from Tribler.dispersy.endpoint import ManualEnpoint -from Tribler.dispersy.member import DummyMember - - -class AbstractTestCommunity(AbstractServer): - - # We have to initialize Dispersy and the tunnel community on the reactor thread - - @inlineCallbacks - def setUp(self): - yield super(AbstractTestCommunity, self).setUp() - self.dispersy = Dispersy(ManualEnpoint(0), self.getStateDir()) - self.dispersy._database.open() - self.master_member = DummyMember(self.dispersy, 1, "a" * 20) - self.member = self.dispersy.get_new_member(u"curve25519") - - @inlineCallbacks - def tearDown(self): - for community in self.dispersy.get_communities(): - yield community.unload_community() - - self.master_member = None - self.member = None - yield super(AbstractTestCommunity, self).tearDown() diff --git a/Tribler/Test/Community/Allchannel/__init__.py b/Tribler/Test/Community/Allchannel/__init__.py deleted file mode 100644 index c952975cc3e..00000000000 --- a/Tribler/Test/Community/Allchannel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests for the AllChannel community. -""" diff --git a/Tribler/Test/Community/Allchannel/test_allchannel_community.py b/Tribler/Test/Community/Allchannel/test_allchannel_community.py deleted file mode 100644 index 1b4c2b794fa..00000000000 --- a/Tribler/Test/Community/Allchannel/test_allchannel_community.py +++ /dev/null @@ -1,43 +0,0 @@ -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import inlineCallbacks - -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.community.channel.preview import PreviewChannelCommunity -from Tribler.dispersy.member import DummyMember -from Tribler.dispersy.message import Message -from Tribler.Test.Community.AbstractTestCommunity import AbstractTestCommunity - - -class TestAllChannelCommunity(AbstractTestCommunity): - - @inlineCallbacks - def setUp(self): - yield super(TestAllChannelCommunity, self).setUp() - self.community = AllChannelCommunity(self.dispersy, self.master_member, self.member) - self.dispersy._communities['a' * 20] = self.community - self.community.initialize(auto_join_channel=True) - - @trial_timeout(10) - def test_create_votecast(self): - """ - Testing whether a votecast can be created in the community - """ - def verify(message): - self.assertTrue(isinstance(message, Message.Implementation)) - - return self.community.disp_create_votecast("c" * 20, 2, 300).addCallback(verify) - - @trial_timeout(10) - def test_unload_preview(self): - """ - Test the unloading of the preview community - """ - def verify_unloaded(_): - self.assertEqual(len(self.dispersy.get_communities()), 1) - - preview_member = DummyMember(self.dispersy, 2, "c" * 20) - preview_community = PreviewChannelCommunity(self.dispersy, preview_member, self.member) - preview_community.initialize() - preview_community.init_timestamp = -500 - self.dispersy._communities['c' * 20] = preview_community - return self.community.unload_preview().addCallback(verify_unloaded) diff --git a/Tribler/Test/Community/Search/FullSession/__init__.py b/Tribler/Test/Community/Search/FullSession/__init__.py deleted file mode 100644 index adcba0c47f9..00000000000 --- a/Tribler/Test/Community/Search/FullSession/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests to test the remote search with real Tribler session. -""" diff --git a/Tribler/Test/Community/Search/FullSession/test_search_community.py b/Tribler/Test/Community/Search/FullSession/test_search_community.py deleted file mode 100644 index 13f0daaaa69..00000000000 --- a/Tribler/Test/Community/Search/FullSession/test_search_community.py +++ /dev/null @@ -1,179 +0,0 @@ -from __future__ import absolute_import -from six import unichr -from twisted.internet import reactor -from twisted.internet.defer import inlineCallbacks, Deferred - -from Tribler.Core.Session import Session -from Tribler.Core.simpledefs import NTFY_TORRENTS, SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, SIGNAL_TORRENT, \ - NTFY_CHANNELCAST -from Tribler.Test.test_as_server import TestAsServer -from Tribler.Test.tools import trial_timeout -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.community.search.community import SearchCommunity -from Tribler.dispersy.candidate import Candidate - -MASTER_KEY = "3081a7301006072a8648ce3d020106052b81040027038192000400f4771c58e65f2cc0385a14027a937a0eb54df0e" \ - "4ae2f72acd8f8286066a48a5e8dcff81c7dfa369fbc33bfe9823587057557cf168b41586dc9ff7615a7e5213f3ec6" \ - "c9b4f9f57f00dbc0dd8ca8b9f6d76fd63a432a56d5938ce9dd7bd291daa92bec52ffcd58d9718836163868f493063" \ - "77c3b8bf36d43ea99122c3276e1a89fb5b9b2ff3f7f6f1702d057dca3e8c0" -MASTER_KEY_SEARCH = "3081a7301006072a8648ce3d020106052b8104002703819200040759eff226a7e2efc62ff61538267f837c" \ - "34d2a32927a10ff31618a69773e4123e405a6d4a930ceeae9a01cfde07496ec21bdb60eb23c92009bf2c93" \ - "f9fd32653953f136e6704d04077c457497cea70d1b3809f7ee7c4fa40faad7d9ed00a622183ae8623fe64e" \ - "1017af273a53b347f11bc6a919c01e9db8f6a98eaf1fcea0a1f18b339b013c7eb134797c29d4c4c429" - - -class AllChannelCommunityTests(AllChannelCommunity): - """ - We define our own AllChannelCommunity. - """ - - @classmethod - def get_master_members(cls, dispersy): - return [dispersy.get_member(public_key=MASTER_KEY.decode("HEX"))] - - @property - def dispersy_enable_fast_candidate_walker(self): - return True - - def check_channelsearch_response(self, messages): - for message in messages: - yield message - - -class SearchCommunityTests(SearchCommunity): - """ - We define our own SearchCommunity. - """ - - @classmethod - def get_master_members(cls, dispersy): - return [dispersy.get_member(public_key=MASTER_KEY_SEARCH.decode("HEX"))] - - -class TestSearchCommunity(TestAsServer): - """ - Contains tests to test remote search with booted Tribler sessions. - """ - - @inlineCallbacks - def setUp(self): - yield super(TestSearchCommunity, self).setUp() - - self.config2 = None - self.session2 = None - self.dispersy2 = None - self.search_community = None - self.allchannel_community = None - - self.dispersy = self.session.get_dispersy_instance() - yield self.setup_peer() - - def setUpPreSession(self): - TestAsServer.setUpPreSession(self) - self.config.set_dispersy_enabled(True) - self.config.set_torrent_store_enabled(True) - self.config.set_torrent_search_enabled(True) - self.config.set_channel_search_enabled(True) - self.config.set_metadata_enabled(True) - self.config.set_channel_community_enabled(True) - self.config.set_preview_channel_community_enabled(True) - self.config.set_torrent_collecting_enabled(True) - self.config.set_torrent_checking_enabled(True) - self.config.set_megacache_enabled(True) - - @inlineCallbacks - def setup_peer(self): - """ - Setup a second peer that contains some search results. - """ - self.setUpPreSession() - - self.config2 = self.config.copy() - self.config2.set_state_dir(self.getStateDir(2)) - - self.session2 = Session(self.config2) - - yield self.session2.start() - self.dispersy2 = self.session2.get_dispersy_instance() - - @inlineCallbacks - def unload_communities(): - for community in self.dispersy.get_communities(): - if isinstance(community, SearchCommunity) or isinstance(community, AllChannelCommunity): - yield community.unload_community() - - for community in self.dispersy2.get_communities(): - if isinstance(community, SearchCommunity) or isinstance(community, AllChannelCommunity): - yield community.unload_community() - - def load_communities(): - self.search_community = \ - self.dispersy.define_auto_load(SearchCommunityTests, self.session.dispersy_member, load=True, - kargs={'tribler_session': self.session})[0] - self.dispersy2.define_auto_load(SearchCommunityTests, self.session2.dispersy_member, load=True, - kargs={'tribler_session': self.session2}) - - self.allchannel_community = \ - self.dispersy.define_auto_load(AllChannelCommunityTests, self.session.dispersy_member, load=True, - kargs={'tribler_session': self.session})[0] - self.dispersy2.define_auto_load(AllChannelCommunityTests, self.session2.dispersy_member, load=True, - kargs={'tribler_session': self.session2}) - - yield unload_communities() - load_communities() - - self.search_community.add_discovered_candidate(Candidate(self.dispersy2.lan_address, tunnel=False)) - self.allchannel_community.add_discovered_candidate(Candidate(self.dispersy2.lan_address, tunnel=False)) - - # Add some content to second session - torrent_db_handler = self.session2.open_dbhandler(NTFY_TORRENTS) - torrent_db_handler.addExternalTorrentNoDef(str(unichr(97)) * 20, 'test test', [('Test.txt', 1337)], [], 1337) - torrent_db_handler.updateTorrent(str(unichr(97)) * 20, is_collected=1) - - channel_db_handler = self.session2.open_dbhandler(NTFY_CHANNELCAST) - channel_db_handler.on_channel_from_dispersy('f' * 20, 42, "test", "channel for unit tests") - torrent_list = [ - [1, 1, 1, ('a' * 40).decode('hex'), 1460000000, "ubuntu-torrent.iso", [['file1.txt', 42]], []] - ] - channel_db_handler.on_torrents_from_dispersy(torrent_list) - - # We also need to add the channel to the database of the session initiating the search - channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - channel_db_handler.on_channel_from_dispersy('f' * 20, 42, "test", "channel for unit tests") - - @trial_timeout(20) - def test_torrent_search(self): - """ - Test whether we receive results when searching remotely for torrents - """ - test_deferred = Deferred() - - def on_search_results_torrents(_dummy1, _dummy2, _dummy3, results): - self.assertEqual(len(results['result_list']), 1) - test_deferred.callback(None) - - reactor.callLater(2, self.session.search_remote_torrents, [u"test"]) - self.session.add_observer(on_search_results_torrents, SIGNAL_TORRENT, [SIGNAL_ON_SEARCH_RESULTS]) - - return test_deferred - - @trial_timeout(20) - def test_channel_search(self): - """ - Test whether we receive results when searching remotely for channels - """ - test_deferred = Deferred() - - def on_search_results_channels(_dummy1, _dummy2, _dummy3, results): - self.assertEqual(len(results['result_list']), 1) - test_deferred.callback(None) - - reactor.callLater(5, self.session.search_remote_channels, [u"test"]) - self.session.add_observer(on_search_results_channels, SIGNAL_CHANNEL, [SIGNAL_ON_SEARCH_RESULTS]) - - return test_deferred - - @inlineCallbacks - def tearDown(self): - yield self.session2.shutdown() - yield super(TestSearchCommunity, self).tearDown() diff --git a/Tribler/Test/Community/Search/__init__.py b/Tribler/Test/Community/Search/__init__.py deleted file mode 100644 index 23b1102cd66..00000000000 --- a/Tribler/Test/Community/Search/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests for the Search community. -""" diff --git a/Tribler/Test/Community/Search/test_search_community.py b/Tribler/Test/Community/Search/test_search_community.py deleted file mode 100644 index 619bb8266b2..00000000000 --- a/Tribler/Test/Community/Search/test_search_community.py +++ /dev/null @@ -1,89 +0,0 @@ -import os - -from nose.tools import raises -from twisted.internet.defer import inlineCallbacks - -from Tribler.Test.Community.AbstractTestCommunity import AbstractTestCommunity -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.common import TESTS_DATA_DIR -from Tribler.community.search.community import SearchCommunity -from Tribler.community.search.conversion import SearchConversion -from Tribler.dispersy.message import DropPacket - - -class TestSearchCommunity(AbstractTestCommunity): - - @inlineCallbacks - def setUp(self): - yield super(TestSearchCommunity, self).setUp() - self.search_community = SearchCommunity(self.dispersy, self.master_member, self.member) - - @inlineCallbacks - def tearDown(self): - self.search_community.cancel_all_pending_tasks() - yield super(TestSearchCommunity, self).tearDown() - - def test_on_search(self): - """ - Test whether we are creating a search response when we receive a search request - """ - def log_incoming_searches(sock_addr, keywords): - log_incoming_searches.called = True - - log_incoming_searches.called = False - - def create_search_response(id, results, candidate): - create_search_response.called = True - self.assertEqual(id, "abc") - self.assertEqual(results, []) - self.assertEqual(candidate.sock_addr, "1234") - - create_search_response.called = False - - def search_names(keywords, local=False, keys=None): - return [] - - self.search_community._torrent_db = MockObject() - self.search_community._torrent_db.searchNames = search_names - - fake_message = MockObject() - fake_message.candidate = MockObject() - fake_message.candidate.sock_addr = "1234" - fake_message.payload = MockObject() - fake_message.payload.keywords = "test" - fake_message.payload.identifier = "abc" - - self.search_community._create_search_response = create_search_response - self.search_community.log_incoming_searches = log_incoming_searches - self.search_community.on_search([fake_message]) - - self.assertTrue(log_incoming_searches.called) - self.assertTrue(create_search_response.called) - - @raises(DropPacket) - def test_decode_response_invalid(self): - """ - Test whether decoding an invalid search response does not crash the program - """ - self.search_community._initialize_meta_messages() - search_conversion = SearchConversion(self.search_community) - search_conversion._decode_search_response(None, 0, "a[]") - - def test_create_torrent(self): - """ - Test the creation of a torrent in the search community - """ - with open(os.path.join(TESTS_DATA_DIR, "bak_single.torrent"), mode='rb') as torrent_file: - torrent_data = torrent_file.read() - - mock_session = MockObject() - mock_session.get_collected_torrent = lambda _: torrent_data - mock_session.open_dbhandler = lambda _: None - mock_session.notifier = None - mock_session.lm = MockObject() - mock_session.lm.rtorrent_handler = None - - self.search_community.initialize(mock_session) - self.search_community._torrent_db = MockObject() - self.search_community._torrent_db.updateTorrent = lambda *_, **ignored: None - self.assertTrue(self.search_community.create_torrent('a' * 20)) diff --git a/Tribler/Test/Community/Tunnel/FullSession/test_tunnel_base.py b/Tribler/Test/Community/Tunnel/FullSession/test_tunnel_base.py index 36b83bec8d1..2bdc0c9bd0c 100644 --- a/Tribler/Test/Community/Tunnel/FullSession/test_tunnel_base.py +++ b/Tribler/Test/Community/Tunnel/FullSession/test_tunnel_base.py @@ -3,7 +3,6 @@ import os from six.moves import xrange - from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks, returnValue from twisted.internet.task import deferLater @@ -63,7 +62,6 @@ def setUp(self): def setUpPreSession(self): TestAsServer.setUpPreSession(self) - self.config.set_dispersy_enabled(False) self.config.set_ipv8_enabled(True) self.config.set_libtorrent_enabled(True) self.config.set_trustchain_enabled(False) @@ -103,9 +101,9 @@ def setup_nodes(self, num_relays=1, num_exitnodes=1, seed_hops=0): self._logger.info("Introducing all nodes to each other in tests") for community_introduce in self.tunnel_communities + ([self.tunnel_community_seeder] if - self.tunnel_community_seeder else []): + self.tunnel_community_seeder else []): for community in self.tunnel_communities + ([self.tunnel_community_seeder] if - self.tunnel_community_seeder else []): + self.tunnel_community_seeder else []): if community != community_introduce: community.walk_to(community_introduce.endpoint.get_address()) @@ -152,7 +150,6 @@ def create_proxy(self, index, exitnode=False): self.setUpPreSession() config = self.config.copy() config.set_libtorrent_enabled(True) - config.set_dispersy_enabled(False) config.set_state_dir(self.getStateDir(index)) config.set_tunnel_community_socks5_listen_ports(self.get_socks5_ports()) @@ -170,7 +167,6 @@ def setup_tunnel_seeder(self, hops): self.seed_config = self.config.copy() self.seed_config.set_state_dir(self.getStateDir(2)) - self.seed_config.set_megacache_enabled(True) self.seed_config.set_tunnel_community_socks5_listen_ports(self.get_socks5_ports()) if self.session2 is None: self.session2 = Session(self.seed_config) diff --git a/Tribler/Test/Community/channel/__init__.py b/Tribler/Test/Community/channel/__init__.py deleted file mode 100644 index efca9dada69..00000000000 --- a/Tribler/Test/Community/channel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains unit tests for the channel community. -""" diff --git a/Tribler/Test/Community/channel/test_channel_base.py b/Tribler/Test/Community/channel/test_channel_base.py deleted file mode 100644 index fd7e1a5e537..00000000000 --- a/Tribler/Test/Community/channel/test_channel_base.py +++ /dev/null @@ -1,21 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Test.Community.AbstractTestCommunity import AbstractTestCommunity -from Tribler.community.channel.community import ChannelCommunity - - -class AbstractTestChannelCommunity(AbstractTestCommunity): - - # We have to initialize Dispersy and the tunnel community on the reactor thread - - @inlineCallbacks - def setUp(self): - yield super(AbstractTestChannelCommunity, self).setUp() - self.channel_community = ChannelCommunity(self.dispersy, self.master_member, self.member) - - @inlineCallbacks - def tearDown(self): - # Don't unload_community() as it never got registered in dispersy on the first place. - self.channel_community.cancel_all_pending_tasks() - self.channel_community = None - yield super(AbstractTestChannelCommunity, self).tearDown() diff --git a/Tribler/Test/Community/channel/test_channel_community.py b/Tribler/Test/Community/channel/test_channel_community.py deleted file mode 100644 index e430fe86f32..00000000000 --- a/Tribler/Test/Community/channel/test_channel_community.py +++ /dev/null @@ -1,56 +0,0 @@ -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Test.Community.channel.test_channel_base import AbstractTestChannelCommunity -from Tribler.Test.Core.base_test import MockObject - - -class TestChannelCommunity(AbstractTestChannelCommunity): - - def test_initialize(self): - def raise_runtime(): - raise RuntimeError() - self.channel_community._get_latest_channel_message = raise_runtime - self.channel_community.initialize() - self.assertIsNone(self.channel_community._channelcast_db) - - def test_remove_playlist_torrents(self): - """ - Testing whether the right methods are called when a torrent is removed from a playlist - """ - def mocked_load_message(undone, community, packet_id): - fake_message = MockObject() - fake_message.undone = undone - return fake_message - - def mocked_create_undo(_): - mocked_create_undo.called = True - mocked_create_undo.called = False - - def mocked_undo_playlist_torrent(_): - mocked_undo_playlist_torrent.called = True - mocked_undo_playlist_torrent.called = False - - self.channel_community.create_undo = mocked_create_undo - self.channel_community._disp_undo_playlist_torrent = mocked_undo_playlist_torrent - - self.channel_community._dispersy.load_message_by_packetid = \ - lambda community, pid: mocked_load_message(False, community, pid) - self.channel_community.remove_playlist_torrents(1234, [1234]) - self.assertTrue(mocked_create_undo.called) - - self.channel_community._dispersy.load_message_by_packetid = \ - lambda community, pid: mocked_load_message(True, community, pid) - self.channel_community.remove_playlist_torrents(1234, [1234]) - self.assertTrue(mocked_undo_playlist_torrent.called) - - def test_create_torrent_from_def(self): - """ - Testing whether a correct Dispersy message is created when we add a torrent to our channel - """ - metainfo = {"info": {"name": "my_torrent", "piece length": 12345, "pieces": "12345678901234567890", - "files": [{'path': ['test.txt'], 'length': 1234}]}} - torrent = TorrentDef.load_from_dict(metainfo) - self.channel_community.initialize() - - message = self.channel_community._disp_create_torrent_from_torrentdef(torrent, 12345) - self.assertEqual(message.payload.name, "my_torrent") - self.assertEqual(len(message.payload.files), 1) diff --git a/Tribler/Test/Community/channel/test_channel_conversion.py b/Tribler/Test/Community/channel/test_channel_conversion.py deleted file mode 100644 index 41f0c8a500f..00000000000 --- a/Tribler/Test/Community/channel/test_channel_conversion.py +++ /dev/null @@ -1,67 +0,0 @@ -import zlib -from struct import pack - -from twisted.internet.defer import inlineCallbacks - -from Tribler.Test.Community.channel.test_channel_base import AbstractTestChannelCommunity -from Tribler.Test.Core.base_test import MockObject -from Tribler.community.channel.conversion import ChannelConversion -from Tribler.dispersy.message import DropPacket -from Tribler.pyipv8.ipv8.messaging.deprecated.encoding import encode - - -class TestChannelConversion(AbstractTestChannelCommunity): - - @inlineCallbacks - def setUp(self): - yield super(TestChannelConversion, self).setUp() - self.channel_community.initialize() - self.conversion = ChannelConversion(self.channel_community) - - self.placeholder = MockObject() - - def test_encode_torrent(self): - """ - Test the encoding of a torrent file - """ - message = MockObject() - message.payload = MockObject() - - message.payload.name = u'test' - message.payload.infohash = 'a' * 20 - message.payload.timestamp = 1234 - message.payload.files = [(u'a', 1234)] - message.payload.trackers = ['udp://tracker.openbittorrent.com:80/announce', 'http://google.com'] - - meta = self.channel_community.get_meta_message(u"torrent") - msg = MockObject() - msg.meta = meta - - decoded_message = self.conversion._decode_torrent(msg, 0, self.conversion._encode_torrent(message)[0])[1] - self.assertEqual(len(decoded_message.files), 1) - self.assertEqual(len(decoded_message.trackers), 1) - - message.payload.files = [(u'a', 1234)] * 1000 - message.payload.trackers = ['udp://tracker.openbittorrent.com:80/announce'] * 100 - - decoded_message = self.conversion._decode_torrent(msg, 0, self.conversion._encode_torrent(message)[0])[1] - self.assertGreaterEqual(len(decoded_message.files), 133) - self.assertEqual(len(decoded_message.trackers), 10) - - def test_decode_torrent(self): - """ - Test the decoding of a torrent message - """ - self.assertRaises(DropPacket, self.conversion._decode_torrent, None, 0, "abcd") - self.assertRaises(DropPacket, self.conversion._decode_torrent, None, 0, zlib.compress("abcd")) - - # Test a successful decoding - meta = self.channel_community.get_meta_message(u"torrent") - msg = MockObject() - msg.meta = meta - - torrent_msg = encode((pack('!20sQ', 'a' * 20, 12345), u'torrent', ((u'a', 1234),), ('http://track.er',))) - _, msg = self.conversion._decode_torrent(msg, 0, zlib.compress(torrent_msg)) - - self.assertEqual(msg.infohash, 'a' * 20) - self.assertEqual(msg.name, u'torrent') diff --git a/Tribler/Test/Community/popularity/test_community.py b/Tribler/Test/Community/popularity/test_community.py index 28bf583f55c..ae2b1f8e001 100644 --- a/Tribler/Test/Community/popularity/test_community.py +++ b/Tribler/Test/Community/popularity/test_community.py @@ -1,223 +1,39 @@ +from __future__ import absolute_import + +import os import random +from pony.orm import db_session +from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Utilities.random_utils import random_infohash, random_string, random_utf8_string -from Tribler.Test.Core.base_test import MockObject -from Tribler.community.popularity import constants -from Tribler.community.popularity.community import PopularityCommunity, MSG_TORRENT_HEALTH_RESPONSE, \ - MSG_CHANNEL_HEALTH_RESPONSE, ERROR_UNKNOWN_PEER, ERROR_NO_CONTENT, \ - ERROR_UNKNOWN_RESPONSE -from Tribler.community.popularity.constants import SEARCH_TORRENT_REQUEST, MSG_TORRENT_INFO_RESPONSE, MSG_SUBSCRIPTION -from Tribler.community.popularity.payload import SearchResponseItemPayload, TorrentInfoResponsePayload, \ - TorrentHealthPayload, ContentSubscription -from Tribler.community.popularity.repository import TYPE_TORRENT_HEALTH -from Tribler.community.popularity.request import ContentRequest + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.community.popularity.community import PopularityCommunity, MSG_TORRENT_HEALTH_RESPONSE +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto from Tribler.pyipv8.ipv8.test.base import TestBase from Tribler.pyipv8.ipv8.test.mocking.ipv8 import MockIPv8 -from Tribler.Test.tools import trial_timeout -class TestPopularityCommunityBase(TestBase): + +class TestPopularityCommunity(TestBase): NUM_NODES = 2 def setUp(self): - super(TestPopularityCommunityBase, self).setUp() + super(TestPopularityCommunity, self).setUp() + self.shared_key = default_eccrypto.generate_key(u"curve25519") self.initialize(PopularityCommunity, self.NUM_NODES) def create_node(self, *args, **kwargs): - def load_random_torrents(limit): - return [ - ['\xfdC\xf9+V\x11A\xe7QG\xfb\xb1*6\xef\xa5\xaeu\xc2\xe0', - random.randint(200, 250), random.randint(1, 10), 1525704192.166107] for _ in range(limit) - ] - - torrent_db = MockObject() - torrent_db.getTorrent = lambda *args, **kwargs: None - torrent_db.updateTorrent = lambda *args, **kwargs: None - torrent_db.getRecentlyCheckedTorrents = load_random_torrents - - channel_db = MockObject() - - return MockIPv8(u"curve25519", PopularityCommunity, torrent_db=torrent_db, channel_db=channel_db) - - -class MockRepository(object): - - def __init__(self): - super(MockRepository, self).__init__() - self.sample_torrents = [] - self.setup_torrents() - - def setup_torrents(self): - for _ in range(10): - infohash = random_infohash() - name = random_utf8_string() - length = random.randint(1000, 9999) - num_files = random.randint(1, 10) - category_list = ['video', 'audio'] - creation_date = random.randint(1000000, 111111111) - seeders = random.randint(10, 200) - leechers = random.randint(5, 1000) - cid = random_string(size=20) - - self.sample_torrents.append([infohash, name, length, num_files, category_list, creation_date, - seeders, leechers, cid]) - - def search_torrent(self, _): - sample_items = [] - for torrent in self.sample_torrents: - sample_items.append(SearchResponseItemPayload(*torrent)) - return sample_items - - def search_channels(self, _): - return [] - - def has_torrent(self, _): - return False - - def cleanup(self): - pass - - def update_from_search_results(self, results): - pass - - def get_torrent(self, _): - torrent = self.sample_torrents[0] - db_torrent = {'name': torrent[1], - 'length': torrent[2], - 'creation_date': torrent[5], - 'num_files': torrent[3], - 'comment': ''} - return db_torrent - - def get_top_torrents(self): - return self.sample_torrents - - def update_from_torrent_search_results(self, search_results): - pass - - -class TestPopularityCommunity(TestPopularityCommunityBase): - __testing__ = False - NUM_NODES = 2 - - @inlineCallbacks - def test_subscribe_peers(self): - """ - Tests subscribing to peers populate publishers and subscribers list. - """ - self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - # Node 0 should have a publisher added - self.assertGreater(len(self.nodes[0].overlay.publishers), 0, "Publisher expected") - # Node 1 should have a subscriber added - self.assertGreater(len(self.nodes[1].overlay.subscribers), 0, "Subscriber expected") - - @inlineCallbacks - def test_subscribe_unsubscribe_individual_peers(self): - """ - Tests subscribing/subscribing an individual peer. - """ - self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe(self.nodes[1].my_peer, subscribe=True) - yield self.deliver_messages() - - self.assertEqual(len(self.nodes[0].overlay.publishers), 1, "Expected one publisher") - self.assertEqual(len(self.nodes[1].overlay.subscribers), 1, "Expected one subscriber") - - self.nodes[0].overlay.subscribe(self.nodes[1].my_peer, subscribe=False) - yield self.deliver_messages() - - self.assertEqual(len(self.nodes[0].overlay.publishers), 0, "Expected no publisher") - self.assertEqual(len(self.nodes[1].overlay.subscribers), 0, "Expected no subscriber") - - def test_unsubscribe_multiple_peers(self): - """ - Tests unsubscribing multiple peers works as expected. - """ - def send_popular_content_subscribe(my_peer, _, subscribe): - if not subscribe: - my_peer.unsubsribe_called += 1 - - self.nodes[0].overlay.subscribe = lambda peer, subscribe: \ - send_popular_content_subscribe(self.nodes[0], peer, subscribe) - - # Add some peers - num_peers = 10 - default_peers = [self.create_node() for _ in range(num_peers)] - self.nodes[0].overlay.get_peers = lambda: default_peers - self.assertEqual(len(self.nodes[0].overlay.get_peers()), num_peers) - - # Add some publishers - for peer in default_peers: - self.nodes[0].overlay.publishers.add(peer) - self.assertEqual(len(self.nodes[0].overlay.publishers), num_peers) - - # Unsubscribe all the peers - self.nodes[0].unsubsribe_called = 0 - self.nodes[0].overlay.unsubscribe_peers() - - # Check if unsubscription was successful - self.assertEqual(self.nodes[0].unsubsribe_called, num_peers) - self.assertEqual(len(self.nodes[0].overlay.publishers), 0) - - def test_refresh_peers(self): - """ - Tests if refresh_peer_list() updates the publishers and subscribers list - """ - default_peers = [self.create_node() for _ in range(10)] - - for peer in default_peers: - self.nodes[0].overlay.publishers.add(peer) - self.nodes[0].overlay.subscribers.add(peer) - - self.nodes[0].overlay.get_peers = lambda: default_peers - self.assertEqual(len(self.nodes[0].overlay.get_peers()), 10) - - # Remove half of the peers and refresh peer list - default_peers = default_peers[:5] - self.nodes[0].overlay.refresh_peer_list() - - # List of publishers and subscribers should be updated - self.assertEqual(len(self.nodes[0].overlay.get_peers()), 5) - self.assertEqual(len(self.nodes[0].overlay.subscribers), 5) - self.assertEqual(len(self.nodes[0].overlay.publishers), 5) - - @trial_timeout(6) - @inlineCallbacks - def test_start(self): - """ - Tests starting of the community. Peer should start subscribing to other connected peers. - """ - self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None - - def fake_refresh_peer_list(peer): - peer.called_refresh_peer_list = True - - def fake_publish_next_content(peer): - peer.called_publish_next_content = True - - self.nodes[0].called_refresh_peer_list = False - self.nodes[0].called_publish_next_content = False - self.nodes[0].overlay.refresh_peer_list = lambda: fake_refresh_peer_list(self.nodes[0]) - self.nodes[0].overlay.publish_next_content = lambda: fake_publish_next_content(self.nodes[0]) + mds = MetadataStore(os.path.join(self.temporary_directory(), 'test.db'), self.temporary_directory(), + self.shared_key) - yield self.introduce_nodes() - self.nodes[0].overlay.start() - yield self.sleep(constants.PUBLISH_INTERVAL) - - # Node 0 should have a publisher added - self.assertEqual(len(self.nodes[0].overlay.publishers), 1, "Expected one publisher") - # Node 1 should have a subscriber added - self.assertEqual(len(self.nodes[1].overlay.subscribers), 1, "Expected one subscriber") + # Add some content to the metadata database + with db_session: + mds.ChannelMetadata.create_channel('test', 'test') + for torrent_ind in xrange(5): + torrent = mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=('%d' % torrent_ind) * 20) + torrent.health.seeders = torrent_ind + 1 - self.assertTrue(self.nodes[0].called_refresh_peer_list) - self.assertTrue(self.nodes[0].called_publish_next_content) + return MockIPv8(u"curve25519", PopularityCommunity, metadata_store=mds) @inlineCallbacks def test_content_publishing(self): @@ -238,7 +54,7 @@ def on_torrent_health_response(peer, source_address, data): # Add something to queue health_info = ('a' * 20, random.randint(1, 100), random.randint(1, 10), random.randint(1, 111111)) - self.nodes[1].overlay.queue_content(TYPE_TORRENT_HEALTH, health_info) + self.nodes[1].overlay.queue_content(health_info) self.nodes[1].overlay.publish_next_content() @@ -247,495 +63,22 @@ def on_torrent_health_response(peer, source_address, data): self.assertTrue(self.nodes[0].torrent_health_response_received, "Expected to receive torrent response") @inlineCallbacks - def test_publish_no_content(self): - """ - Tests publishing next content if no content is available. - """ - original_logger = self.nodes[0].overlay.logger - self.nodes[0].overlay.logger.debug = lambda *args, **kw: self.fake_logger_error(self.nodes[0], *args) - - # Assume a subscribers exist - self.nodes[0].overlay.subscribers = [self.create_node()] - # No content - self.nodes[0].overlay.content_repository.pop_content = lambda: (None, None) - - # Try publishing the next available content - self.nodes[0].no_content = False - self.nodes[0].overlay.publish_next_content() - yield self.deliver_messages() - - # Expect no content found to be logged - self.assertTrue(self.nodes[0].no_content) - - # Restore logger - self.nodes[0].overlay.logger = original_logger - - @inlineCallbacks - def test_send_torrent_health_response(self): - """ - Tests sending torrent health response. - """ - original_logger = self.nodes[0].overlay.logger - self.nodes[0].overlay.logger.debug = lambda *args, **kw: self.fake_logger_error(self.nodes[0], *args) - - self.nodes[0].overlay.create_message_packet = lambda _type, _payload: \ - self.fake_create_message_packet(self.nodes[0], _type, _payload) - self.nodes[0].overlay.broadcast_message = lambda packet, peer: \ - self.fake_broadcast_message(self.nodes[0], packet, peer) - - # Two default peers - default_peers = [self.create_node() for _ in range(2)] - # Assuming only one is connected - self.nodes[0].overlay.get_peers = lambda: default_peers[:1] - - # Case1: Try to send subscribe response to non-connected peer - self.nodes[0].unknown_peer_found = False - self.nodes[0].logger_error_called = False - payload = MockObject() - self.nodes[0].overlay.send_torrent_health_response(payload, peer=default_peers[1]) - yield self.deliver_messages() - - # Expected unknown peer error log - self.assertTrue(self.nodes[0].logger_error_called) - self.assertTrue(self.nodes[0].unknown_peer_found) - - # Case2: Try to send response to the connected peer - self.nodes[0].broadcast_called = False - self.nodes[0].broadcast_packet_type = None - self.nodes[0].overlay.send_torrent_health_response(payload, peer=default_peers[0]) - yield self.deliver_messages() - - # Expect message to be sent - self.assertTrue(self.nodes[0].packet_created, "Create packet failed") - self.assertEqual(self.nodes[0].packet_type, MSG_TORRENT_HEALTH_RESPONSE, "Unexpected payload type found") - self.assertTrue(self.nodes[0].broadcast_called, "Should send a message to the peer") - self.assertEqual(self.nodes[0].receiver, default_peers[0], "Intended receiver is different") - - # Restore logger - self.nodes[0].overlay.logger = original_logger - - @inlineCallbacks - def test_send_channel_health_response(self): + def test_publish_latest_torrents(self): """ - Tests sending torrent health response. + Test publishing all latest torrents """ - original_logger = self.nodes[0].overlay.logger - self.nodes[0].overlay.logger.debug = lambda *args, **kw: self.fake_logger_error(self.nodes[0], *args) - - self.nodes[0].overlay.create_message_packet = lambda _type, _payload: \ - self.fake_create_message_packet(self.nodes[0], _type, _payload) - self.nodes[0].overlay.broadcast_message = lambda packet, peer: \ - self.fake_broadcast_message(self.nodes[0], packet, peer) - - # Two default peers - default_peers = [self.create_node() for _ in range(2)] - # Assuming only one is connected - self.nodes[0].overlay.get_peers = lambda: default_peers[:1] - - # Case1: Try to send response to non-connected peer - self.nodes[0].unknown_peer_found = False - self.nodes[0].logger_error_called = False - payload = MockObject() - self.nodes[0].overlay.send_channel_health_response(payload, peer=default_peers[1]) - yield self.deliver_messages() - - # Expected unknown peer error log - self.assertTrue(self.nodes[0].logger_error_called) - self.assertTrue(self.nodes[0].unknown_peer_found) - - # Case2: Try to send response to the connected peer - self.nodes[0].broadcast_called = False - self.nodes[0].broadcast_packet_type = None - self.nodes[0].overlay.send_channel_health_response(payload, peer=default_peers[0]) - yield self.deliver_messages() - - # Expect message to be sent - self.assertTrue(self.nodes[0].packet_created, "Create packet failed") - self.assertEqual(self.nodes[0].packet_type, MSG_CHANNEL_HEALTH_RESPONSE, "Unexpected payload type found") - self.assertTrue(self.nodes[0].broadcast_called, "Should send a message to the peer") - self.assertEqual(self.nodes[0].receiver, default_peers[0], "Intended receiver is different") - - # Restore logger - self.nodes[0].overlay.logger = original_logger - - @inlineCallbacks - def test_send_torrent_info_request_response(self): - """ Test if torrent info request response works as expected. """ - self.nodes[1].called_send_torrent_info_response = False - original_send_torrent_info_response = self.nodes[1].overlay.send_torrent_info_response - - def send_torrent_info_response(node, infohash, peer): - node.called_infohash = infohash - node.called_peer = peer - node.called_send_torrent_info_response = True - - self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: \ - send_torrent_info_response(self.nodes[1], infohash, peer) - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - infohash = 'a'*20 - self.nodes[0].overlay.send_torrent_info_request(infohash, self.nodes[1].my_peer) + self.nodes[1].overlay.subscribe_peers() yield self.deliver_messages() - self.assertTrue(self.nodes[1].called_send_torrent_info_response) - self.nodes[1].overlay.send_torrent_info_response = original_send_torrent_info_response - - @inlineCallbacks - def test_send_content_info_request_response(self): - """ Test if content info request response works as expected """ - - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None + # Update the health of some torrents + with db_session: + torrents = self.nodes[0].overlay.content_repository.get_top_torrents() + torrents[0].health.seeders = 500 - self.nodes[1].called_send_content_info_response = False - - def send_content_info_response(node, peer, content_type): - node.called_send_content_info_response = True - node.called_peer = peer - node.called_content_type = content_type - - self.nodes[1].overlay.send_content_info_response = lambda peer, identifier, content_type, _: \ - send_content_info_response(self.nodes[1], peer, content_type) - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - content_type = SEARCH_TORRENT_REQUEST - request_list = ['ubuntu'] - self.nodes[0].overlay.send_content_info_request(content_type, request_list, peer=self.nodes[1].my_peer) + self.nodes[0].overlay.publish_latest_torrents(self.nodes[1].overlay.my_peer) yield self.deliver_messages() - self.assertTrue(self.nodes[1].called_send_content_info_response) - - @inlineCallbacks - def test_on_torrent_health_response_from_unknown_peer(self): - """ - Tests receiving torrent health response from unknown peer - """ - original_logger = self.nodes[0].overlay.logger - self.nodes[0].overlay.logger.error = lambda *args, **kw: self.fake_logger_error(self.nodes[0], *args) - - infohash = 'a' * 20 - num_seeders = 10 - num_leechers = 5 - timestamp = 123123123 - - payload = TorrentHealthPayload(infohash, num_seeders, num_leechers, timestamp) - source_address = ('1.1.1.1', 1024) - data = self.nodes[0].overlay.create_message_packet(MSG_TORRENT_HEALTH_RESPONSE, payload) - - self.nodes[0].unknown_response = False - self.nodes[0].overlay.on_torrent_health_response(source_address, data) - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].unknown_response) - - # Restore logger - self.nodes[0].overlay.logger = original_logger - - @inlineCallbacks - def test_on_torrent_health_response(self): - """ - Tests receiving torrent health response from unknown peer - """ - def fake_update_torrent(peer): - peer.called_update_torrent = True - - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[0].overlay.content_repository.update_torrent_health = lambda payload, peer_trust: \ - fake_update_torrent(self.nodes[0]) - - infohash = 'a' * 20 - num_seeders = 10 - num_leechers = 5 - timestamp = 123123123 - - payload = TorrentHealthPayload(infohash, num_seeders, num_leechers, timestamp) - data = self.nodes[1].overlay.create_message_packet(MSG_TORRENT_HEALTH_RESPONSE, payload) - - yield self.introduce_nodes() - - # Add node 1 in publisher list of node 0 - self.nodes[0].overlay.publishers.add(self.nodes[1].my_peer) - self.nodes[0].overlay.on_torrent_health_response(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].called_update_torrent) - - @inlineCallbacks - def test_on_torrent_info_response(self): - """ - Tests receiving torrent health response. - """ - def fake_update_torrent_info(peer): - peer.called_update_torrent = True - - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[0].overlay.content_repository.update_torrent_info = lambda payload: \ - fake_update_torrent_info(self.nodes[0]) - - infohash = 'a' * 20 - name = "ubuntu" - length = 100 - creation_date = 123123123 - num_files = 33 - comment = '' - - payload = TorrentInfoResponsePayload(infohash, name, length, creation_date, num_files, comment) - data = self.nodes[1].overlay.create_message_packet(MSG_TORRENT_INFO_RESPONSE, payload) - - yield self.introduce_nodes() - - # Add node 1 in publisher list of node 0 - self.nodes[0].overlay.publishers.add(self.nodes[1].my_peer) - self.nodes[0].overlay.on_torrent_info_response(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].called_update_torrent) - - @inlineCallbacks - def test_on_torrent_info_response_from_unknown_peer(self): - """ - Tests receiving torrent health response from unknown peer. - """ - - def fake_update_torrent_info(peer): - peer.called_update_torrent = True - - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[0].overlay.content_repository.update_torrent_info = lambda payload: \ - fake_update_torrent_info(self.nodes[0]) - - infohash = 'a' * 20 - name = "ubuntu" - length = 100 - creation_date = 123123123 - num_files = 33 - comment = '' - - payload = TorrentInfoResponsePayload(infohash, name, length, creation_date, num_files, comment) - data = self.nodes[1].overlay.create_message_packet(MSG_TORRENT_INFO_RESPONSE, payload) - - yield self.introduce_nodes() - - self.nodes[0].called_update_torrent = False - self.nodes[0].overlay.on_torrent_info_response(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertFalse(self.nodes[0].called_update_torrent) - - @inlineCallbacks - def test_on_subscription_status(self): - """ - Tests receiving subscription status. - """ - subscribe = True - identifier = 123123123 - payload = ContentSubscription(identifier, subscribe) - data = self.nodes[1].overlay.create_message_packet(MSG_SUBSCRIPTION, payload) - # Set the cache request - self.nodes[0].overlay.request_cache.pop = lambda prefix, identifer: MockObject() - self.nodes[0].overlay.request_cache.has = lambda prefix, identifer: True - - yield self.introduce_nodes() - self.assertEqual(len(self.nodes[0].overlay.publishers), 0) - - self.nodes[0].overlay.on_subscription_status(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertEqual(len(self.nodes[0].overlay.publishers), 1) - - @inlineCallbacks - def test_on_subscription_status_no_cache(self): - """ - Tests receiving subscription status when request is not available in cache. - """ - subscribe = True - identifier = 123123123 - payload = ContentSubscription(identifier, subscribe) - data = self.nodes[1].overlay.create_message_packet(MSG_SUBSCRIPTION, payload) - - # Assume cache request is present - self.nodes[0].overlay.request_cache.has = lambda prefix, identifer: False - - yield self.introduce_nodes() - self.assertEqual(len(self.nodes[0].overlay.publishers), 0) - - self.nodes[0].overlay.on_subscription_status(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertEqual(len(self.nodes[0].overlay.publishers), 0) - - @inlineCallbacks - def test_on_subscription_status_with_unsubscribe(self): - """ - Tests receiving subscription status with unsubscribe status. - """ - yield self.introduce_nodes() - self.nodes[0].overlay.publishers.add(self.nodes[1].my_peer) - self.assertEqual(len(self.nodes[0].overlay.publishers), 1) - # Set the cache request - self.nodes[0].overlay.request_cache.pop = lambda prefix, identifer: MockObject() - self.nodes[0].overlay.request_cache.has = lambda prefix, identifer: True - - subscribe = False - identifier = 123123123 - payload = ContentSubscription(identifier, subscribe) - data = self.nodes[1].overlay.create_message_packet(MSG_SUBSCRIPTION, payload) - - self.nodes[0].overlay.on_subscription_status(self.nodes[1].my_peer.address, data) - yield self.deliver_messages() - - self.assertEqual(len(self.nodes[0].overlay.publishers), 0) - - @inlineCallbacks - def test_search_request_response(self): - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - - def fake_process_torrent_search_response(peer): - peer.called_process_torrent_search_response = True - - self.nodes[0].overlay.process_torrent_search_response = lambda query, payload: \ - fake_process_torrent_search_response(self.nodes[0]) - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - # Create a search request - query = "ubuntu" - self.nodes[0].overlay.send_torrent_search_request(query) - - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].called_process_torrent_search_response) - - @inlineCallbacks - def test_process_search_response(self): - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - - def fake_notify(peer, result_dict): - peer.called_search_result_notify = True - self.assertEqual(result_dict['keywords'], 'ubuntu') - self.assertGreater(len(result_dict['results']), 1) - - self.nodes[0].overlay.tribler_session = MockObject() - self.nodes[0].overlay.tribler_session.notifier = MockObject() - self.nodes[0].overlay.tribler_session.notifier.notify = lambda signal1, signal2, _, result_dict: \ - fake_notify(self.nodes[0], result_dict) - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - # Create a search request - query = "ubuntu" - self.nodes[0].called_search_result_notify = False - - self.nodes[0].overlay.send_torrent_search_request(query) - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].called_search_result_notify) - - @inlineCallbacks - def test_send_content_info_request(self): - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - - self.nodes[0].received_response = False - self.nodes[0].received_query = None - - def process_torrent_search_response(node, query): - node.received_response = True - node.received_query = query - - self.nodes[0].overlay.process_torrent_search_response = lambda query, data: \ - process_torrent_search_response(self.nodes[0], query) - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - content_type = SEARCH_TORRENT_REQUEST - request_list = ["ubuntu"] - self.nodes[0].overlay.send_content_info_request(content_type, request_list, limit=5, peer=None) - yield self.deliver_messages() - - self.assertTrue(self.nodes[0].received_response) - self.assertEqual(self.nodes[0].received_query, request_list) - - @inlineCallbacks - def test_send_torrent_info_response(self): - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - - self.nodes[0].called_on_torrent_info_response = False - - def on_torrent_info_response(node): - node.called_on_torrent_info_response = True - - self.nodes[0].overlay.decode_map[chr(MSG_TORRENT_INFO_RESPONSE)] = lambda _source_address, _data: \ - on_torrent_info_response(self.nodes[0]) - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - infohash = 'a'*20 - self.nodes[1].overlay.send_torrent_info_response(infohash, self.nodes[0].my_peer) - yield self.deliver_messages() - self.assertTrue(self.nodes[0].called_on_torrent_info_response) - - @inlineCallbacks - def test_search_request_timeout(self): - """ - Test whether the callback is called with an empty list when the search request times out - """ - ContentRequest.CONTENT_TIMEOUT = 0.1 - - self.nodes[0].overlay.content_repository = MockRepository() - self.nodes[1].overlay.content_repository = MockRepository() - self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None - - yield self.introduce_nodes() - self.nodes[0].overlay.subscribe_peers() - yield self.deliver_messages() - - # Make sure that the other node does not respond to our search query - self.nodes[1].overlay.send_content_info_response = lambda *_, **__: None - - def on_results(results): - self.assertIsInstance(results, list) - self.assertFalse(results) - - content_type = SEARCH_TORRENT_REQUEST - deferred = self.nodes[0].overlay.send_content_info_request(content_type, ["ubuntu"], limit=5, peer=None) - yield deferred.addCallback(on_results) - - def fake_logger_error(self, my_peer, *args): - if ERROR_UNKNOWN_PEER in args[0]: - my_peer.unknown_peer_found = True - if ERROR_NO_CONTENT in args[0]: - my_peer.no_content = True - if ERROR_UNKNOWN_RESPONSE in args[0]: - my_peer.unknown_response = True - my_peer.logger_error_called = True - - def fake_create_message_packet(self, my_peer, _type, _payload): - my_peer.packet_created = True - my_peer.packet_type = _type - - def fake_broadcast_message(self, my_peer, _, peer): - my_peer.broadcast_called = True - my_peer.receiver = peer + with db_session: + torrents = self.nodes[1].overlay.content_repository.get_top_torrents() + self.assertEqual(torrents[0].health.seeders, 500) diff --git a/Tribler/Test/Community/popularity/test_payload.py b/Tribler/Test/Community/popularity/test_payload.py index 31fb28755be..3d5a8f72608 100644 --- a/Tribler/Test/Community/popularity/test_payload.py +++ b/Tribler/Test/Community/popularity/test_payload.py @@ -1,12 +1,10 @@ from __future__ import absolute_import + import random import string from unittest import TestCase -from six.moves import xrange -from Tribler.community.popularity.payload import SearchResponsePayload, SearchResponseItemPayload, ContentInfoRequest, \ - Pagination, ContentInfoResponse, ContentSubscription, TorrentHealthPayload, ChannelHealthPayload, \ - TorrentInfoResponsePayload, encode_values, decode_values +from Tribler.community.popularity.payload import ContentSubscription, TorrentHealthPayload, decode_values, encode_values from Tribler.pyipv8.ipv8.messaging.serialization import Serializer @@ -59,156 +57,3 @@ def test_torrent_health_payload(self): self.assertEqual(num_seeders, deserialized_payload.num_seeders) self.assertEqual(num_leechers, deserialized_payload.num_leechers) self.assertEqual(timestamp, deserialized_payload.timestamp) - - def test_channel_health_payload(self): - """ Test serialization/deserialization of Channel health payload """ - channel_id = self.random_string(size=20) - num_votes = 100 - num_torrents = 5 - swarm_size_sum = 20 - timestamp = 123123123 - - health_payload = ChannelHealthPayload(channel_id, num_votes, num_torrents, swarm_size_sum, timestamp) - serialized = self.serializer.pack_multiple(health_payload.to_pack_list())[0] - - # Deserialize and test it - (deserialized, _) = self.serializer.unpack_multiple(ChannelHealthPayload.format_list, serialized) - deserialized_payload = ChannelHealthPayload.from_unpack_list(*deserialized) - - self.assertEqual(channel_id, deserialized_payload.channel_id) - self.assertEqual(num_votes, deserialized_payload.num_votes) - self.assertEqual(num_torrents, deserialized_payload.num_torrents) - self.assertEqual(swarm_size_sum, deserialized_payload.swarm_size_sum) - self.assertEqual(timestamp, deserialized_payload.timestamp) - - def test_torrent_info_response_payload_for_default_values(self): - """ Test serialization/deserialization of Torrent health info response payload for default values. """ - infohash = 'a' * 20 - name = None - length = None - creation_date = None - num_files = None - comment = None - - health_payload = TorrentInfoResponsePayload(infohash, name, length, creation_date, num_files, comment) - serialized = self.serializer.pack_multiple(health_payload.to_pack_list())[0] - - # Deserialize and test it - (deserialized, _) = self.serializer.unpack_multiple(TorrentInfoResponsePayload.format_list, serialized) - deserialized_payload = TorrentInfoResponsePayload.from_unpack_list(*deserialized) - - self.assertEqual(infohash, deserialized_payload.infohash) - self.assertEqual('', deserialized_payload.name) - self.assertEqual(0, deserialized_payload.length) - self.assertEqual(0, deserialized_payload.creation_date) - self.assertEqual(0, deserialized_payload.num_files) - self.assertEqual('', deserialized_payload.comment) - - def test_search_result_payload_serialization(self): - """ Test serialization & deserialization of search payload """ - # sample search response items - sample_items = [] - for index in range(10): - infohash = self.random_infohash() - name = self.random_string() - length = random.randint(1000, 9999) - num_files = random.randint(1, 10) - category_list = ['video', 'audio'] - creation_date = random.randint(1000000, 111111111) - seeders = random.randint(10, 200) - leechers = random.randint(5, 1000) - cid = self.random_string(size=20) - - sample_items.append(SearchResponseItemPayload(infohash, name, length, num_files, category_list, - creation_date, seeders, leechers, cid)) - - # Search identifier - identifier = 111 - response_type = 1 - - # Serialize the results - results = '' - for item in sample_items: - results += self.serializer.pack_multiple(item.to_pack_list())[0] - serialized_results = self.serializer.pack_multiple( - SearchResponsePayload(identifier, response_type, results).to_pack_list())[0] - - # De-serialize the response payload and check the identifier and get the results - response_format = SearchResponsePayload.format_list - (search_results, _) = self.serializer.unpack_multiple(response_format, serialized_results) - - # De-serialize each individual search result items - item_format = SearchResponseItemPayload.format_list - (all_items, _) = self.serializer.unpack_multiple_as_list(item_format, search_results[2]) - for index in xrange(len(all_items)): - response_item = SearchResponseItemPayload.from_unpack_list(*all_items[index]) - sample_item = sample_items[index] - - self.assertEqual(sample_item.infohash, response_item.infohash) - self.assertEqual(sample_item.name, response_item.name) - self.assertEqual(sample_item.length, response_item.length) - self.assertEqual(sample_item.num_files, response_item.num_files) - self.assertEqual(sample_item.creation_date, response_item.creation_date) - self.assertEqual(sample_item.category_list, response_item.category_list) - self.assertEqual(sample_item.seeders, response_item.seeders) - self.assertEqual(sample_item.leechers, response_item.leechers) - self.assertEqual(sample_item.cid, response_item.cid) - - def test_pagination(self): - """ Test if pagination serialization & deserialization works as expected. """ - page_num = 1 - page_size = 10 - max_results = 50 - more = False - - page = Pagination(page_num, page_size, max_results, more) - serialized_page = page.serialize() - - # Deserialize and test the parameters - deserialized_page = Pagination.deserialize(serialized_page) - self.assertEqual(page.page_number, deserialized_page.page_number) - self.assertEqual(page.page_size, deserialized_page.page_size) - self.assertEqual(page.max_results, deserialized_page.max_results) - self.assertEqual(page.more, deserialized_page.more) - - def test_content_info_request(self): - """ Test serialization & deserialization of content info request """ - identifier = 1 - content_type = 1 - query_list = "ubuntu 18.04".split() - limit = 10 - - # Serialize request - in_request = ContentInfoRequest(identifier, content_type, query_list, limit) - serialized_request = self.serializer.pack_multiple(in_request.to_pack_list())[0] - - # Deserialize request and test it - (deserialized_request, _) = self.serializer.unpack_multiple(ContentInfoRequest.format_list, serialized_request) - out_request = ContentInfoRequest.from_unpack_list(*deserialized_request) - self.assertEqual(in_request.identifier, out_request.identifier) - self.assertEqual(in_request.query_list, out_request.query_list) - self.assertEqual(in_request.content_type, out_request.content_type) - self.assertEqual(in_request.limit, out_request.limit) - - def test_content_info_response(self): - """ Test serialization & deserialization of content info response """ - identifier = 1 - content_type = 1 - response = self.random_string(size=128) - more = True - pagination = Pagination(1, 10, 50, more) - - # Serialize request - in_response = ContentInfoResponse(identifier, content_type, response, pagination) - serialized_response = self.serializer.pack_multiple(in_response.to_pack_list())[0] - - # Deserialize request and test it - (deserialized_response, _) = self.serializer.unpack_multiple(ContentInfoResponse.format_list, - serialized_response) - out_request = ContentInfoResponse.from_unpack_list(*deserialized_response) - self.assertEqual(in_response.identifier, out_request.identifier) - self.assertEqual(in_response.response, out_request.response) - self.assertEqual(in_response.content_type, out_request.content_type) - self.assertEqual(in_response.pagination.page_number, out_request.pagination.page_number) - self.assertEqual(in_response.pagination.page_size, out_request.pagination.page_size) - self.assertEqual(in_response.pagination.max_results, out_request.pagination.max_results) diff --git a/Tribler/Test/Community/popularity/test_pubsub_community.py b/Tribler/Test/Community/popularity/test_pubsub_community.py new file mode 100644 index 00000000000..d3f9f281d1a --- /dev/null +++ b/Tribler/Test/Community/popularity/test_pubsub_community.py @@ -0,0 +1,140 @@ +from __future__ import absolute_import + +from twisted.internet.defer import inlineCallbacks + +from Tribler.Test.tools import trial_timeout +from Tribler.community.popularity.constants import PUBLISH_INTERVAL +from Tribler.community.popularity.pubsub import PubSubCommunity +from Tribler.pyipv8.ipv8.test.base import TestBase +from Tribler.pyipv8.ipv8.test.mocking.ipv8 import MockIPv8 + + +class TestPubSubCommunity(TestBase): + NUM_NODES = 2 + + def setUp(self): + super(TestPubSubCommunity, self).setUp() + self.initialize(PubSubCommunity, self.NUM_NODES) + + def create_node(self, *args, **kwargs): + return MockIPv8(u"curve25519", PubSubCommunity) + + @inlineCallbacks + def test_subscribe_peers(self): + """ + Tests subscribing to peers populate publishers and subscribers list. + """ + self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None + yield self.introduce_nodes() + self.nodes[0].overlay.subscribe_peers() + yield self.deliver_messages() + + # Node 0 should have a publisher added + self.assertGreater(len(self.nodes[0].overlay.publishers), 0, "Publisher expected") + # Node 1 should have a subscriber added + self.assertGreater(len(self.nodes[1].overlay.subscribers), 0, "Subscriber expected") + + @inlineCallbacks + def test_subscribe_unsubscribe_individual_peers(self): + """ + Tests subscribing/subscribing an individual peer. + """ + self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None + self.nodes[1].overlay.publish_latest_torrents = lambda *args, **kwargs: None + + yield self.introduce_nodes() + self.nodes[0].overlay.subscribe(self.nodes[1].my_peer, subscribe=True) + yield self.deliver_messages() + + self.assertEqual(len(self.nodes[0].overlay.publishers), 1, "Expected one publisher") + self.assertEqual(len(self.nodes[1].overlay.subscribers), 1, "Expected one subscriber") + + self.nodes[0].overlay.subscribe(self.nodes[1].my_peer, subscribe=False) + yield self.deliver_messages() + + self.assertEqual(len(self.nodes[0].overlay.publishers), 0, "Expected no publisher") + self.assertEqual(len(self.nodes[1].overlay.subscribers), 0, "Expected no subscriber") + + def test_unsubscribe_multiple_peers(self): + """ + Tests unsubscribing multiple peers works as expected. + """ + + def send_popular_content_subscribe(my_peer, _, subscribe): + if not subscribe: + my_peer.unsubsribe_called += 1 + + self.nodes[0].overlay.subscribe = lambda peer, subscribe: \ + send_popular_content_subscribe(self.nodes[0], peer, subscribe) + + # Add some peers + num_peers = 10 + default_peers = [self.create_node() for _ in range(num_peers)] + self.nodes[0].overlay.get_peers = lambda: default_peers + self.assertEqual(len(self.nodes[0].overlay.get_peers()), num_peers) + + # Add some publishers + for peer in default_peers: + self.nodes[0].overlay.publishers.add(peer) + self.assertEqual(len(self.nodes[0].overlay.publishers), num_peers) + + # Unsubscribe all the peers + self.nodes[0].unsubsribe_called = 0 + self.nodes[0].overlay.unsubscribe_peers() + + # Check if unsubscription was successful + self.assertEqual(self.nodes[0].unsubsribe_called, num_peers) + self.assertEqual(len(self.nodes[0].overlay.publishers), 0) + + def test_refresh_peers(self): + """ + Tests if refresh_peer_list() updates the publishers and subscribers list + """ + default_peers = [self.create_node() for _ in range(10)] + + for peer in default_peers: + self.nodes[0].overlay.publishers.add(peer) + self.nodes[0].overlay.subscribers.add(peer) + + self.nodes[0].overlay.get_peers = lambda: default_peers + self.assertEqual(len(self.nodes[0].overlay.get_peers()), 10) + + # Remove half of the peers and refresh peer list + default_peers = default_peers[:5] + self.nodes[0].overlay.refresh_peer_list() + + # List of publishers and subscribers should be updated + self.assertEqual(len(self.nodes[0].overlay.get_peers()), 5) + self.assertEqual(len(self.nodes[0].overlay.subscribers), 5) + self.assertEqual(len(self.nodes[0].overlay.publishers), 5) + + @trial_timeout(6) + @inlineCallbacks + def test_start(self): + """ + Tests starting of the community. Peer should start subscribing to other connected peers. + """ + self.nodes[1].overlay.send_torrent_info_response = lambda infohash, peer: None + + def fake_refresh_peer_list(peer): + peer.called_refresh_peer_list = True + + def fake_publish_next_content(peer): + peer.called_publish_next_content = True + + self.nodes[0].called_refresh_peer_list = False + self.nodes[0].called_publish_next_content = False + self.nodes[0].overlay.refresh_peer_list = lambda: fake_refresh_peer_list(self.nodes[0]) + self.nodes[0].overlay.publish_next_content = lambda: fake_publish_next_content(self.nodes[0]) + + yield self.introduce_nodes() + self.nodes[0].overlay.start() + yield self.sleep(PUBLISH_INTERVAL) + + # Node 0 should have a publisher added + self.assertEqual(len(self.nodes[0].overlay.publishers), 1, "Expected one publisher") + # Node 1 should have a subscriber added + self.assertEqual(len(self.nodes[1].overlay.subscribers), 1, "Expected one subscriber") + + self.assertTrue(self.nodes[0].called_refresh_peer_list) + self.assertTrue(self.nodes[0].called_publish_next_content) diff --git a/Tribler/Test/Community/popularity/test_repository.py b/Tribler/Test/Community/popularity/test_repository.py index 029c4e4b2be..aaccedcc4e2 100644 --- a/Tribler/Test/Community/popularity/test_repository.py +++ b/Tribler/Test/Community/popularity/test_repository.py @@ -1,393 +1,83 @@ +from __future__ import absolute_import + import os -import random -import string -import tarfile import time -import unittest -from binascii import unhexlify -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler -from Tribler.Core.CacheDB.sqlitecachedb import SQLiteCacheDB -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.Core.test_sqlitecachedbhandler import BUSYTIMEOUT -from Tribler.Test.common import TESTS_DATA_DIR +from pony.orm import db_session +from six.moves import xrange +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.community.popularity.payload import TorrentHealthPayload -from Tribler.community.popularity.repository import ContentRepository, DEFAULT_FRESHNESS_LIMIT -from Tribler.pyipv8.ipv8.test.base import TestBase +from Tribler.community.popularity.repository import ContentRepository +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto -class TestContentRepository(unittest.TestCase): +class TestContentRepository(TriblerCoreTest): + @inlineCallbacks def setUp(self): - torrent_db = MockObject() - channel_db = MockObject() - self.content_repository = ContentRepository(torrent_db, channel_db) + yield super(TestContentRepository, self).setUp() + self.my_key = default_eccrypto.generate_key(u"curve25519") + mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, self.my_key) + self.content_repository = ContentRepository(mds) + + # Add some content to the metadata database + with db_session: + mds.ChannelMetadata.create_channel('test', 'test') + for torrent_ind in xrange(5): + torrent = mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=('%d' % torrent_ind) * 20) + torrent.health.seeders = torrent_ind + 1 + + def test_has_get_torrent(self): + """ + Test fetching a torrent from the metadata store + """ + self.assertFalse(self.content_repository.get_torrent('9' * 20)) + self.assertTrue(self.content_repository.get_torrent('0' * 20)) + self.assertFalse(self.content_repository.has_torrent('9' * 20)) + self.assertTrue(self.content_repository.has_torrent('0' * 20)) + self.assertFalse(self.content_repository.get_torrent('\x89' * 20)) + + @db_session + def test_get_top_torrents(self): + """ + Test fetching the top torrents from the metadata store + """ + torrents = self.content_repository.get_top_torrents() + self.assertEqual(len(torrents), 5) + self.assertEqual(torrents[0].health.seeders, 5) + + self.assertEqual(len(self.content_repository.get_top_torrents(limit=1)), 1) def test_add_content(self): """ Test adding and removing content works as expected. """ # Initial content queue is zero - self.assertEqual(self.content_repository.count_content(), 0, "No item expected in queue initially") + self.assertEqual(self.content_repository.queue_length(), 0, "No item expected in queue initially") # Add a sample content and check the size - sample_content = ('a' * 20, 6, 3, 123456789) - sample_content_type = 1 - self.content_repository.add_content(sample_content_type, sample_content) - self.assertEqual(self.content_repository.count_content(), 1, "One item expected in queue") + torrent = self.content_repository.get_torrent('0' * 20) + self.content_repository.add_content_to_queue(torrent) + self.assertEqual(self.content_repository.queue_length(), 1, "One item expected in queue") # Pop an item - (content_type, content) = self.content_repository.pop_content() - self.assertEqual(content_type, sample_content_type, "Content type should be equal") - self.assertEqual(content, sample_content, "Content should be equal") + content = self.content_repository.pop_content() + self.assertEqual(content, torrent, "Content should be equal") # Check size again - self.assertEqual(self.content_repository.count_content(), 0, "No item expected in queue") - - def test_get_top_torrents(self): - """ - Test if content repository returns expected top torrents. - """ - - def get_fake_torrents(limit): - return [[chr(x) * 20, x, 0, 1525704192] for x in range(limit)] - - self.content_repository.torrent_db.getRecentlyCheckedTorrents = get_fake_torrents - - limit = 10 - self.assertEqual(self.content_repository.get_top_torrents(limit=limit), get_fake_torrents(limit)) + self.assertEqual(self.content_repository.queue_length(), 0, "No item expected in queue") def test_update_torrent_health(self): """ Tests update torrent health. """ - def update_torrent(repo, _): - repo.update_torrent_called = True - - # Assume a fake torrent response - fake_torrent_health_payload = TorrentHealthPayload('a' * 20, 10, 4, time.time()) - - self.content_repository.torrent_db = MockObject() - self.content_repository.torrent_db.updateTorrent = lambda infohash, *args, **kw: \ - update_torrent(self.content_repository, infohash) - - # If torrent does not exist in the database, then it should be added to the database - self.content_repository.has_torrent = lambda infohash: False + fake_torrent_health_payload = TorrentHealthPayload('0' * 20, 10, 4, time.time()) self.content_repository.update_torrent_health(fake_torrent_health_payload, peer_trust=0) - self.assertTrue(self.content_repository.update_torrent_called) - - def test_update_torrent_with_higher_trust(self): - """ - Scenario: The database torrent has still fresh last_check_time and you receive a new response from - peer with trust > 1. - Expect: Torrent in database is updated. - """ - # last_check_time for existing torrent in database - db_last_time_check = time.time() - 10 - # Peer trust, higher than 1 in this scenario - peer_trust = 10 - - # Database record is expected to be updated - self.assertTrue(self.try_torrent_update_with_options(db_last_time_check, peer_trust)) - - def test_update_torrent_with_stale_check_time(self): - """ - Scenario: The database torrent has stale last_check_time and you receive a new response from - peer with no previous trust. - Expect: Torrent in database is still updated. - """ - # last_check_time for existing torrent in database - db_last_time_check = time.time() - DEFAULT_FRESHNESS_LIMIT - # Peer trust, higher than 1 in this scenario - peer_trust = 0 - - # Database record is expected to be updated - self.assertTrue(self.try_torrent_update_with_options(db_last_time_check, peer_trust)) - - def try_torrent_update_with_options(self, db_last_check_time, peer_trust): - """ - Tries updating torrent considering the given last check time of existing torrent and a new response - obtained from a peer with given peer_trust value. - """ - sample_infohash, seeders, leechers, timestamp = 'a' * 20, 10, 5, db_last_check_time - sample_payload = TorrentHealthPayload(sample_infohash, seeders, leechers, timestamp) - - def update_torrent(content_repo, _): - content_repo.update_torrent_called = True - - def get_torrent(infohash): - return {'infohash': infohash, 'num_seeders': seeders, - 'num_leechers': leechers, 'last_tracker_check': timestamp} - - self.content_repository.torrent_db.getTorrent = lambda infohash, **kw: get_torrent(infohash) - self.content_repository.torrent_db.hasTorrent = lambda infohash: infohash == sample_infohash - self.content_repository.torrent_db.updateTorrent = \ - lambda infohash, *args, **kw: update_torrent(self.content_repository, infohash) - - self.content_repository.update_torrent_called = False - self.content_repository.update_torrent_health(sample_payload, peer_trust=peer_trust) - - return self.content_repository.update_torrent_called - - def test_update_torrent_info(self): - """ Test updating torrent info """ - self.content_repository.called_update_torrent = False - - def fake_update_torrent(ref): - ref.called_update_torrent = True - - self.content_repository.torrent_db.updateTorrent = lambda infohash, **kw: \ - fake_update_torrent(self.content_repository) - self.content_repository.has_torrent = lambda infohash: False - torrent_info_response = MockObject() - torrent_info_response.infohash = 'a' * 20 - - torrent_info_response.name = 'ubuntu' - torrent_info_response.length = 123 - torrent_info_response.creation_date = 123123123 - torrent_info_response.num_files = 2 - torrent_info_response.comment = 'Ubuntu ISO' - - self.content_repository.update_torrent_info(torrent_info_response) - self.assertTrue(self.content_repository.called_update_torrent) - - def test_update_conflicting_torrent_info(self): - """ Test updating torrent info response with existing record in the database.""" - torrent_info_response = MockObject() - torrent_info_response.infohash = 'a' * 20 - torrent_info_response.name = 'ubuntu' - torrent_info_response.length = 123 - torrent_info_response.creation_date = 123123123 - torrent_info_response.num_files = 2 - torrent_info_response.comment = 'Ubuntu ISO' - - self.content_repository.called_update_torrent = False - - def fake_update_torrent(ref): - ref.called_update_torrent = True - - def fake_get_torrent(infohash, name): - torrent = {'infohash': infohash, 'name': name} - return torrent - - self.content_repository.torrent_db.updateTorrent = lambda infohash, **kw: fake_update_torrent( - self.content_repository) - self.content_repository.has_torrent = lambda infohash: True - self.content_repository.get_torrent = lambda infohash: fake_get_torrent(infohash, torrent_info_response.name) - - self.content_repository.update_torrent_info(torrent_info_response) - self.assertFalse(self.content_repository.called_update_torrent) - - def test_search_torrent(self): - """ Test torrent search """ - def random_string(size=6, chars=string.ascii_uppercase + string.digits): - return ''.join(random.choice(chars) for _ in range(size)) - - def random_infohash(): - return ''.join(random.choice('0123456789abcdef') for _ in range(20)) - - sample_torrents = [] - for _ in range(10): - infohash = random_infohash() - name = random_string() - length = random.randint(1000, 9999) - num_files = random.randint(1, 10) - category_list = ['video', 'audio'] - creation_date = random.randint(1000000, 111111111) - seeders = random.randint(10, 200) - leechers = random.randint(5, 1000) - cid = random_string(size=20) - - sample_torrents.append([infohash, name, length, num_files, category_list, creation_date, seeders, - leechers, cid]) - - def fake_torrentdb_search_names(_): - return sample_torrents - - self.content_repository.torrent_db.searchNames = lambda query, **kw: fake_torrentdb_search_names(query) - - search_query = "Ubuntu" - search_results = self.content_repository.search_torrent(search_query) - - for index in range(10): - db_torrent = sample_torrents[index] - search_result = search_results[index] - - self.assertEqual(db_torrent[0], search_result.infohash) - self.assertEqual(db_torrent[1], search_result.name) - self.assertEqual(db_torrent[2], search_result.length) - self.assertEqual(db_torrent[3], search_result.num_files) - self.assertEqual(db_torrent[6], search_result.seeders) - self.assertEqual(db_torrent[7], search_result.leechers) - - def test_search_channel(self): - """ Test channel search """ - def random_string(size=6, chars=string.ascii_uppercase + string.digits): - return ''.join(random.choice(chars) for _ in range(size)) - - sample_channels = [] - for index in range(10): - dbid = index - cid = random_string(size=20) - name = random_string() - description = random_string(20) - nr_torrents = random.randint(1, 10) - nr_favorite = random.randint(1, 10) - nr_spam = random.randint(1, 10) - my_vote = 1 - modified = random.randint(1, 10000000) - relevance_score = 0.0 - - sample_channels.append([dbid, cid, name, description, nr_torrents, nr_favorite, nr_spam, my_vote, - modified, relevance_score]) - - def fake_torrentdb_search_channels(_): - return sample_channels - - self.content_repository.channel_db.search_in_local_channels_db = lambda query, **kw: \ - fake_torrentdb_search_channels(query) - - search_query = "Ubuntu" - search_results = self.content_repository.search_channels(search_query) - - for index in range(10): - db_channel = sample_channels[index] - search_result = search_results[index] - - self.assertEqual(db_channel[0], search_result.id) - self.assertEqual(db_channel[1], search_result.cid) - self.assertEqual(db_channel[2], search_result.name) - self.assertEqual(db_channel[3], search_result.description) - self.assertEqual(db_channel[4], search_result.nr_torrents) - self.assertEqual(db_channel[5], search_result.nr_favorite) - self.assertEqual(db_channel[6], search_result.nr_spam) - self.assertEqual(db_channel[8], search_result.modified) - - def test_update_torrent_from_search_results(self): - """ Tests updating database from the search results """ - def random_string(size=6, chars=string.ascii_uppercase + string.digits): - return ''.join(random.choice(chars) for _ in range(size)) - - def random_infohash(): - return ''.join(random.choice('0123456789abcdef') for _ in range(20)) - - search_results = dict() - for _ in range(10): - infohash = random_infohash() - name = random_string() - length = random.randint(1000, 9999) - num_files = random.randint(1, 10) - category_list = ['video', 'audio'] - creation_date = random.randint(1000000, 111111111) - seeders = random.randint(10, 200) - leechers = random.randint(5, 1000) - cid = random_string(size=20) - - search_results[infohash] = [infohash, name, length, num_files, category_list, creation_date, - seeders, leechers, cid] - - def get_torrent(torrent_as_list): - return {'infohash': torrent_as_list[0], - 'name': torrent_as_list[1], - 'length': torrent_as_list[2], - 'num_files': torrent_as_list[3], - 'category_list': torrent_as_list[4], - 'creation_date': torrent_as_list[5], - 'seeders': torrent_as_list[6], - 'leechers': torrent_as_list[7], - 'cid': torrent_as_list[8]} - - def fake_update_torrent(ref): - ref.called_update_torrent = True - - def fake_add_or_get_torrent_id(ref): - ref.called_add_or_get_torrent_id = True - - self.content_repository.torrent_db.updateTorrent = lambda infohash, **kw: fake_update_torrent( - self.content_repository) - self.content_repository.torrent_db.addOrGetTorrentID = lambda infohash: fake_add_or_get_torrent_id( - self.content_repository) - - # Case 1: Assume torrent does not exist in the database - self.content_repository.has_torrent = lambda infohash: False - self.content_repository.get_torrent = lambda infohash: None - - self.content_repository.torrent_db._db = MockObject() - self.content_repository.torrent_db._db.commit_now = lambda x=None: None - - self.content_repository.called_update_torrent = False - self.content_repository.update_from_torrent_search_results(search_results.values()) - self.assertTrue(self.content_repository.called_update_torrent) - self.assertTrue(self.content_repository.called_add_or_get_torrent_id) - - # Case 2: Torrent already exist in the database - self.content_repository.has_torrent = lambda infohash: infohash in search_results - self.content_repository.get_torrent = lambda infohash: get_torrent(search_results[infohash]) - - self.content_repository.called_update_torrent = False - self.content_repository.called_add_or_get_torrent_id = False - self.content_repository.update_from_torrent_search_results(search_results.values()) - self.assertFalse(self.content_repository.called_update_torrent) - self.assertFalse(self.content_repository.called_add_or_get_torrent_id) - - -class TestContentRepositoryWithRealDatabase(TestBase): - """ - Tests content repository with real database. - """ - - def setUp(self): - super(TestContentRepositoryWithRealDatabase, self).setUp() - - session_base_dir = self.temporary_directory() - tar = tarfile.open(os.path.join(TESTS_DATA_DIR, 'bak_new_tribler.sdb.tar.gz'), 'r|gz') - tar.extractall(session_base_dir) - db_path = os.path.join(session_base_dir, 'bak_new_tribler.sdb') - self.sqlitedb = SQLiteCacheDB(db_path, busytimeout=BUSYTIMEOUT) - - session = MockObject() - session.sqlite_db = self.sqlitedb - session.notifier = MockObject() - - self.torrent_db = TorrentDBHandler(session) - channel_db = MockObject() - self.content_repository = ContentRepository(self.torrent_db, channel_db) - - def tearDown(self): - self.torrent_db.close() - self.sqlitedb.close() - return super(TestContentRepositoryWithRealDatabase, self).tearDown() - - def test_update_db_from_search_results(self): - """ - Test if database is properly updated with the search results. - Should not raise any UnicodeDecodeError. - """ - # Add a torrent infohash before updating from search results - infohash = unhexlify('ed81da94d21ad1b305133f2726cdaec5a57fed98') - self.content_repository.torrent_db.addOrGetTorrentID(infohash) - - # Sample search results - name = 'Puppy.Linux.manual.301.espa\xc3\xb1ol.pdf' - length = random.randint(1000, 9999) - num_files = random.randint(1, 10) - category_list = ['other'] - creation_date = random.randint(1000000, 111111111) - seeders = random.randint(10, 200) - leechers = random.randint(5, 1000) - cid = None - search_results = [[infohash, name, length, num_files, category_list, creation_date, seeders, leechers, cid]] - - # Update from search results - self.content_repository.update_from_torrent_search_results(search_results) - - # Check if database has correct results - torrent_info = self.content_repository.get_torrent(infohash) - expected_name = u'Puppy.Linux.manual.301.espa\xc3\xb1ol.pdf' - self.assertEqual(expected_name, torrent_info['name']) - self.assertEqual(seeders, torrent_info['num_seeders']) - self.assertEqual(leechers, torrent_info['num_leechers']) - self.assertEqual(creation_date, torrent_info['creation_date']) - self.assertEqual(num_files, torrent_info['num_files']) - self.assertEqual(length, torrent_info['length']) + with db_session: + torrent = self.content_repository.get_torrent('0' * 20) + self.assertEqual(torrent.health.seeders, 10) + self.assertEqual(torrent.health.leechers, 4) diff --git a/Tribler/Test/Core/Config/test_tribler_config.py b/Tribler/Test/Core/Config/test_tribler_config.py index d5a738d2e2f..8b2e9c90193 100644 --- a/Tribler/Test/Core/Config/test_tribler_config.py +++ b/Tribler/Test/Core/Config/test_tribler_config.py @@ -4,7 +4,7 @@ from configobj import ConfigObj -from Tribler.Core.Config.tribler_config import TriblerConfig, CONFIG_SPEC_PATH, FILENAME +from Tribler.Core.Config.tribler_config import CONFIG_SPEC_PATH, FILENAME, TriblerConfig from Tribler.Test.Core.base_test import TriblerCoreTest @@ -127,9 +127,6 @@ def test_get_set_methods_general(self): self.tribler_config.set_trustchain_testnet_keypair_filename("TEST") self.assertEqual(self.tribler_config.get_trustchain_testnet_keypair_filename(), "TEST") - self.tribler_config.set_megacache_enabled(True) - self.assertEqual(self.tribler_config.get_megacache_enabled(), True) - self.tribler_config.set_testnet(True) self.assertTrue(self.tribler_config.get_testnet()) @@ -164,21 +161,14 @@ def test_get_set_methods_http_api(self): self.tribler_config.set_http_api_retry_port(True) self.assertTrue(self.tribler_config.get_http_api_retry_port()) - def test_get_set_methods_dispersy(self): - """ - Check whether dispersy get and set methods are working as expected. - """ - self.tribler_config.set_dispersy_enabled(True) - self.assertEqual(self.tribler_config.get_dispersy_enabled(), True) - self.tribler_config.set_dispersy_port(True) - self.assertEqual(self.tribler_config.get_dispersy_port(), True) - def test_get_set_methods_ipv8(self): """ Check whether IPv8 get and set methods are working as expected. """ self.tribler_config.set_ipv8_enabled(False) self.assertEqual(self.tribler_config.get_ipv8_enabled(), False) + self.tribler_config.set_ipv8_port(1234) + self.assertEqual(self.tribler_config.get_ipv8_port(), 1234) self.tribler_config.set_ipv8_bootstrap_override("127.0.0.1:12345") self.assertEqual(self.tribler_config.get_ipv8_bootstrap_override(), ("127.0.0.1", 12345)) self.tribler_config.set_ipv8_statistics(True) @@ -257,16 +247,6 @@ def test_get_set_methods_tunnel_community(self): self.tribler_config.set_tunnel_community_competing_slots(20) self.assertEqual(self.tribler_config.get_tunnel_community_competing_slots(), 20) - def test_get_set_methods_torrent_store(self): - """ - Check whether torrent store get and set methods are working as expected. - """ - self.tribler_config.set_torrent_store_enabled(True) - self.assertEqual(self.tribler_config.get_torrent_store_enabled(), True) - self.tribler_config.set_torrent_store_dir("TESTDIR") - self.tribler_config.set_state_dir("TEST") - self.assertEqual(self.tribler_config.get_torrent_store_dir(), os.path.join("TEST", "TESTDIR")) - def test_get_set_methods_wallets(self): """ Check whether wallet get and set methods are working as expected. @@ -293,55 +273,6 @@ def test_get_set_is_matchmaker(self): self.tribler_config.set_is_matchmaker(False) self.assertFalse(self.tribler_config.get_is_matchmaker()) - def test_get_set_methods_metadata(self): - """ - Check whether metadata get and set methods are working as expected. - """ - self.tribler_config.set_metadata_enabled(True) - self.assertEqual(self.tribler_config.get_metadata_enabled(), True) - self.tribler_config.set_metadata_store_dir("TESTDIR") - self.tribler_config.set_state_dir("TEST") - self.assertEqual(self.tribler_config.get_metadata_store_dir(), os.path.join("TEST", "TESTDIR")) - - def test_get_set_methods_torrent_collecting(self): - """ - Check whether torrent collecting get and set methods are working as expected. - """ - self.tribler_config.set_torrent_collecting_enabled(True) - self.assertEqual(self.tribler_config.get_torrent_collecting_enabled(), True) - self.tribler_config.set_torrent_collecting_max_torrents(True) - self.assertEqual(self.tribler_config.get_torrent_collecting_max_torrents(), True) - self.tribler_config.set_torrent_collecting_dir(True) - self.assertEqual(self.tribler_config.get_torrent_collecting_dir(), True) - - def test_get_set_methods_search_community(self): - """ - Check whether search community get and set methods are working as expected. - """ - self.tribler_config.set_torrent_search_enabled(True) - self.assertEqual(self.tribler_config.get_torrent_search_enabled(), True) - - def test_get_set_methods_allchannel_community(self): - """ - Check whether allchannel community get and set methods are working as expected. - """ - self.tribler_config.set_channel_search_enabled(True) - self.assertEqual(self.tribler_config.get_channel_search_enabled(), True) - - def test_get_set_methods_channel_community(self): - """ - Check whether channel community get and set methods are working as expected. - """ - self.tribler_config.set_channel_community_enabled(True) - self.assertEqual(self.tribler_config.get_channel_community_enabled(), True) - - def test_get_set_methods_preview_channel_community(self): - """ - Check whether preview channel community get and set methods are working as expected. - """ - self.tribler_config.set_preview_channel_community_enabled(True) - self.assertEqual(self.tribler_config.get_preview_channel_community_enabled(), True) - def test_get_set_methods_popularity_community(self): """ Check whether popularity community get and set methods are working as expected. diff --git a/Tribler/Test/Core/CreditMining/test_credit_mining_manager.py b/Tribler/Test/Core/CreditMining/test_credit_mining_manager.py index baadf8307ac..ccaf1ebee26 100644 --- a/Tribler/Test/Core/CreditMining/test_credit_mining_manager.py +++ b/Tribler/Test/Core/CreditMining/test_credit_mining_manager.py @@ -94,7 +94,7 @@ class TestCreditMiningManager(TestAsServer): def __init__(self, *argv, **kwargs): super(TestCreditMiningManager, self).__init__(*argv, **kwargs) # Some fake data for convenience - self.cid = '0' * 40 + self.cid = '0' * 64 self.infohash = '0' * 40 self.infohash_bin = '\00' * 20 self.name = u'torrent' @@ -107,11 +107,9 @@ def setUp(self): def setUpPreSession(self): super(TestCreditMiningManager, self).setUpPreSession() - self.config.set_megacache_enabled(True) - self.config.set_dispersy_enabled(True) self.config.set_libtorrent_enabled(True) self.config.set_credit_mining_enabled(True) - self.config.set_market_community_enabled(False) + self.config.set_chant_enabled(True) def test_source_add_remove(self): self.credit_mining_manager.add_source(self.cid) diff --git a/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py b/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py index 99ef856bac8..e7426c2c2dd 100644 --- a/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py +++ b/Tribler/Test/Core/CreditMining/test_credit_mining_sources.py @@ -3,18 +3,15 @@ Author(s): Mihai Capota, Ardhi Putra """ +from __future__ import absolute_import -from binascii import unhexlify, hexlify -from twisted.internet import reactor +from pony.orm import db_session -from twisted.internet.defer import inlineCallbacks -from twisted.internet.task import deferLater +from twisted.internet.defer import Deferred from Tribler.Core.CreditMining.CreditMiningSource import ChannelSource -from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_DISCOVERED, NTFY_TORRENT -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.community.channel.community import ChannelCommunity from Tribler.Test.test_as_server import TestAsServer +from Tribler.Test.tools import trial_timeout class TestCreditMiningSources(TestAsServer): @@ -22,55 +19,18 @@ class TestCreditMiningSources(TestAsServer): Class to test the credit mining sources """ - def __init__(self, *argv, **kwargs): - super(TestCreditMiningSources, self).__init__(*argv, **kwargs) - # Fake channel id for testing - self.cid = '0' * 40 - def setUpPreSession(self): super(TestCreditMiningSources, self).setUpPreSession() - self.config.set_megacache_enabled(True) - self.config.set_dispersy_enabled(True) - self.config.set_channel_search_enabled(True) + self.config.set_chant_enabled(True) + @trial_timeout(5) def test_channel_lookup(self): - source = ChannelSource(self.session, self.cid, lambda: None) - source.start() - self.assertIsInstance(source.community, ChannelCommunity, 'ChannelSource failed to create ChannelCommunity') - source.stop() - - def test_existing_channel_lookup(self): - # Find AllChannel - for community in self.session.lm.dispersy.get_communities(): - if isinstance(community, AllChannelCommunity): - allchannelcommunity = community - - # Load the channel - community = ChannelCommunity.init_community(self.session.lm.dispersy, - self.session.lm.dispersy.get_member(mid=unhexlify(self.cid)), - allchannelcommunity.my_member, - self.session) + test_deferred = Deferred() - # Check if we find the channel - source = ChannelSource(self.session, self.cid, lambda: None) - source.start() - self.assertEqual(source.community, community, 'ChannelSource failed to find existing ChannelCommunity') - source.stop() + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.create_channel('test', 'test') + _ = self.session.lm.mds.TorrentMetadata(title='testtorrent') - def test_torrent_discovered(self): - torrent_inserteds = [] - torrent_insert_callback = lambda source, infohash, name: torrent_inserteds.append((source, infohash, name)) - source = ChannelSource(self.session, self.cid, torrent_insert_callback) + source = ChannelSource(self.session, str(my_channel.public_key), lambda *_: test_deferred.callback(None)) source.start() - - source.on_torrent_discovered(NTFY_TORRENT, NTFY_DISCOVERED, self.cid, {'dispersy_cid': self.cid, - 'infohash': '\00' * 20, - 'name': 'torrent'}) - self.assertIn((self.cid, '\00' * 20, 'torrent'), torrent_inserteds, 'ChannelSource failed to insert torrent') - - source.on_torrent_discovered(NTFY_TORRENT, NTFY_DISCOVERED, self.cid, {'dispersy_cid': '1' * 40, - 'infohash': '\01' * 20, - 'name': 'torrent'}) - self.assertTrue(len(torrent_inserteds) == 1, 'ChannelSource inserted torrent with wrong dispersy_cid') - - source.stop() + return test_deferred diff --git a/Tribler/Test/Core/Libtorrent/test_libtorrent_download_impl.py b/Tribler/Test/Core/Libtorrent/test_libtorrent_download_impl.py index 33ee2f32248..2ebd9cc7e64 100644 --- a/Tribler/Test/Core/Libtorrent/test_libtorrent_download_impl.py +++ b/Tribler/Test/Core/Libtorrent/test_libtorrent_download_impl.py @@ -30,11 +30,8 @@ class TestLibtorrentDownloadImpl(TestAsServer): def setUpPreSession(self): super(TestLibtorrentDownloadImpl, self).setUpPreSession() self.config.set_torrent_checking_enabled(False) - self.config.set_megacache_enabled(True) - self.config.set_dispersy_enabled(False) self.config.set_tunnel_community_enabled(False) self.config.set_mainline_dht_enabled(False) - self.config.set_torrent_collecting_enabled(False) self.config.set_libtorrent_enabled(True) self.config.set_video_server_enabled(False) @@ -481,7 +478,6 @@ def mocked_checkpoint(): self.libtorrent_download_impl.checkpoint = mocked_checkpoint self.libtorrent_download_impl.session = MockObject() self.libtorrent_download_impl.session.lm = MockObject() - self.libtorrent_download_impl.session.lm.rtorrent_handler = None self.libtorrent_download_impl.session.lm.torrent_db = None self.libtorrent_download_impl.handle.save_path = lambda: None self.libtorrent_download_impl.handle.prioritize_files = lambda _: None diff --git a/Tribler/Test/Core/Libtorrent/test_libtorrent_mgr.py b/Tribler/Test/Core/Libtorrent/test_libtorrent_mgr.py index 5c8e789eed7..5ac5e919adc 100644 --- a/Tribler/Test/Core/Libtorrent/test_libtorrent_mgr.py +++ b/Tribler/Test/Core/Libtorrent/test_libtorrent_mgr.py @@ -1,22 +1,22 @@ +from __future__ import absolute_import + import binascii import os import shutil import tempfile from libtorrent import bencode - -from twisted.internet.task import deferLater - -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import inlineCallbacks, Deferred from twisted.internet import reactor +from twisted.internet.defer import inlineCallbacks, Deferred +from twisted.internet.task import deferLater -from Tribler.Core.CacheDB.Notifier import Notifier +from Tribler.Core.Notifier import Notifier from Tribler.Core.Libtorrent.LibtorrentDownloadImpl import LibtorrentDownloadImpl from Tribler.Core.Libtorrent.LibtorrentMgr import LibtorrentMgr from Tribler.Core.exceptions import TorrentFileException from Tribler.Test.Core.base_test import MockObject from Tribler.Test.test_as_server import AbstractServer +from Tribler.Test.tools import trial_timeout class TestLibtorrentMgr(AbstractServer): diff --git a/Tribler/Test/Core/Modules/Channel/__init__.py b/Tribler/Test/Core/Modules/Channel/__init__.py deleted file mode 100644 index e403d5f7a98..00000000000 --- a/Tribler/Test/Core/Modules/Channel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests for the channel management objects. -""" diff --git a/Tribler/Test/Core/Modules/Channel/test_channel.py b/Tribler/Test/Core/Modules/Channel/test_channel.py deleted file mode 100644 index a756a7a01dd..00000000000 --- a/Tribler/Test/Core/Modules/Channel/test_channel.py +++ /dev/null @@ -1,30 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Modules.channel.channel import ChannelObject -from Tribler.Core.Modules.channel.channel_rss import ChannelRssParser -from Tribler.Test.Core.base_test_channel import BaseTestChannel - - -class TestChannel(BaseTestChannel): - """ - This class contains some tests for the ChannelObject class. - """ - - @inlineCallbacks - def setUp(self): - """ - Setup the tests by creating the ChannelObject instance. - """ - yield super(TestChannel, self).setUp() - self.channel_object = ChannelObject(self.fake_session, self.fake_channel_community) - - def test_get_channel_id(self): - self.assertEqual(self.channel_object.channel_id, 42) - - def test_get_channel_name(self): - self.assertEqual(self.channel_object.name, "my fancy channel") - - def test_get_rss_feed_url_list(self): - rss_parser = ChannelRssParser(self.fake_session, self.fake_channel_community, 'a') - self.channel_object._rss_feed_dict['a'] = rss_parser - self.assertEqual(self.channel_object.get_rss_feed_url_list(), ['a']) diff --git a/Tribler/Test/Core/Modules/Channel/test_channel_manager.py b/Tribler/Test/Core/Modules/Channel/test_channel_manager.py deleted file mode 100644 index 1faff5ff528..00000000000 --- a/Tribler/Test/Core/Modules/Channel/test_channel_manager.py +++ /dev/null @@ -1,45 +0,0 @@ -from twisted.internet.defer import inlineCallbacks -from twisted.python.log import removeObserver - -from Tribler.Core.Config.tribler_config import TriblerConfig -from Tribler.Core.Modules.channel.channel import ChannelObject -from Tribler.Core.Modules.channel.channel_manager import ChannelManager -from Tribler.Core.Session import Session -from Tribler.Core.exceptions import DuplicateChannelNameError -from Tribler.Test.Core.base_test import TriblerCoreTest - - -class TestChannelManager(TriblerCoreTest): - - @inlineCallbacks - def setUp(self): - yield super(TestChannelManager, self).setUp() - self.session = None - - @inlineCallbacks - def tearDown(self): - removeObserver(self.session.unhandled_error_observer) - yield super(TestChannelManager, self).tearDown() - - def test_create_channel_duplicate_name_error(self): - config = TriblerConfig() - config.set_state_dir(self.getStateDir()) - self.session = Session(config) - - class LmMock(object): - channel_manager = ChannelManager(self.session) - - self.session.lm = LmMock() - - class MockCommunity(object): - cid = "" - - def get_channel_name(self): - return "Channel name" - - channel_obj = ChannelObject(self.session, MockCommunity(), is_created=True) - self.session.lm.channel_manager._channel_list = [channel_obj] - - with self.assertRaises(DuplicateChannelNameError) as cm: - self.session.lm.channel_manager.create_channel("Channel name", "description", "open") - self.assertEqual(cm.exception.message, u"Channel name already exists: Channel name") diff --git a/Tribler/Test/Core/Modules/Channel/test_channel_rss.py b/Tribler/Test/Core/Modules/Channel/test_channel_rss.py deleted file mode 100644 index 3a94f1fff07..00000000000 --- a/Tribler/Test/Core/Modules/Channel/test_channel_rss.py +++ /dev/null @@ -1,138 +0,0 @@ -import os -import shutil - -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Modules.channel.cache import SimpleCache -from Tribler.Core.Modules.channel.channel_rss import ChannelRssParser, RSSFeedParser -from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Test.Core.base_test import TriblerCoreTest -from Tribler.Test.Core.base_test_channel import BaseTestChannel -from Tribler.Test.common import TESTS_DATA_DIR - - -class TestChannelRss(BaseTestChannel): - - @inlineCallbacks - def setUp(self): - """ - Setup the tests by creating the ChannelRssParser instance and initializing it. - """ - yield super(TestChannelRss, self).setUp() - self.channel_rss = ChannelRssParser(self.fake_session, self.fake_channel_community, 'a') - self.channel_rss.initialize() - - # Setup a test rss file server - test_rss_file = os.path.join(TESTS_DATA_DIR, 'test_rss.xml') - files_path = os.path.join(self.session_base_dir, 'files') - os.mkdir(files_path) - shutil.copyfile(test_rss_file, os.path.join(files_path, 'test_rss.xml')) - self.file_server_port = get_random_port() - self.setUpFileServer(self.file_server_port, files_path) - - @inlineCallbacks - def tearDown(self): - if self.channel_rss.running: - self.channel_rss.shutdown() - - yield super(TestChannelRss, self).tearDown() - - @trial_timeout(10) - def test_task_scrape_no_stop(self): - self.channel_rss.rss_url = 'http://localhost:%d/test_rss.xml' % self.file_server_port - self.channel_rss.cancel_all_pending_tasks() - test_deferred = self.channel_rss._task_scrape() - self.assertTrue(self.channel_rss.is_pending_task_active("rss_scrape")) - return test_deferred - - @trial_timeout(10) - def test_task_scrape_stop(self): - self.channel_rss.rss_url = 'http://localhost:%d/test_rss.xml' % self.file_server_port - self.channel_rss.cancel_all_pending_tasks() - self.channel_rss._to_stop = True - test_deferred = self.channel_rss._task_scrape() - self.assertFalse(self.channel_rss.is_pending_task_active("rss_scrape")) - return test_deferred - - def test_initialize(self): - self.assertTrue(self.channel_rss.is_pending_task_active("rss_scrape")) - - def test_shutdown(self): - cache_path = self.channel_rss._url_cache._file_path - self.channel_rss._url_cache.add('a') - self.channel_rss.shutdown() - self.assertTrue(os.path.exists(cache_path)) - self.assertFalse(self.channel_rss.is_pending_task_active("rss_scrape")) - - @trial_timeout(10) - def test_parse_rss_feed(self): - """ - Test parsing a rss feed - """ - self.channel_rss.rss_url = 'http://localhost:%d/test_rss.xml' % self.file_server_port - - def verify_rss(items): - self.assertEqual(len(items), 2) - - return self.channel_rss.parse_feed().addCallback(verify_rss) - - @trial_timeout(10) - def test_parse_no_rss(self): - """ - Test parsing a non-rss feed - """ - self.channel_rss.rss_url = 'http://localhost:%d/test_rsszz.xml' % self.file_server_port - - def verify_rss(items): - self.assertIsNone(items) - - return self.channel_rss.parse_feed().addCallback(verify_rss) - - @trial_timeout(10) - def test_parse_feed_stopped(self): - """ - Test whether items are not parsed anymore when the parse feeder is stopped - """ - self.channel_rss.rss_url = 'http://localhost:%d/test_rss.xml' % self.file_server_port - self.channel_rss._url_cache = SimpleCache(os.path.join(self.session_base_dir, 'cache.txt')) - self.channel_rss._to_stop = True - - def verify_rss(items): - self.assertEqual(len(items), 0) - - return self.channel_rss.parse_feed().addCallback(verify_rss) - - -class TestRssParser(TriblerCoreTest): - - def test_parse_html(self): - parser = RSSFeedParser() - self.assertEqual(parser._parse_html("

Hi

"), set()) - self.assertEqual(parser._parse_html(""), {'abc'}) - self.assertEqual(parser._parse_html(""), {'abc', 'def'}) - - def test_html2plaintext(self): - parser = RSSFeedParser() - self.assertEqual(parser._html2plaintext("

test

"), "test\n") - self.assertEqual(parser._html2plaintext("test"), "test\n") - self.assertEqual(parser._html2plaintext("

test\ntest2

test3

"), "test\ntest2\ntest3\n") - - @trial_timeout(10) - def test_parse(self): - test_rss_file = os.path.join(TESTS_DATA_DIR, 'test_rss.xml') - files_path = os.path.join(self.session_base_dir, 'files') - os.mkdir(files_path) - shutil.copyfile(test_rss_file, os.path.join(files_path, 'test_rss.xml')) - file_server_port = get_random_port() - self.setUpFileServer(file_server_port, files_path) - - parser = RSSFeedParser() - cache = SimpleCache(os.path.join(self.session_base_dir, 'cache.txt')) - cache.add('http://localhost:RANDOMPORT/ubuntu.torrent') - - def on_items(rss_items): - self.assertEqual(len(rss_items), 2) - self.assertEqual(len(rss_items[0]['thumbnail_list']), 1) - - return parser.parse('http://localhost:%d/test_rss.xml' % file_server_port, cache).addCallback(on_items) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 9c6e1692d5c..4107d9a28c0 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -1,9 +1,11 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import + import os -from binascii import hexlify from datetime import datetime from pony.orm import db_session +from six.moves import xrange from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.store import MetadataStore diff --git a/Tribler/Test/Core/Modules/RestApi/base_api_test.py b/Tribler/Test/Core/Modules/RestApi/base_api_test.py index c2ba56833ce..f9c063a4ac2 100644 --- a/Tribler/Test/Core/Modules/RestApi/base_api_test.py +++ b/Tribler/Test/Core/Modules/RestApi/base_api_test.py @@ -1,18 +1,20 @@ +from __future__ import absolute_import + import os import urllib from twisted.internet import reactor -from twisted.internet.defer import succeed, inlineCallbacks -from twisted.python.threadable import isInIOThread -from twisted.web.client import Agent, readBody, HTTPConnectionPool +from twisted.internet.defer import inlineCallbacks, succeed +from twisted.web.client import Agent, HTTPConnectionPool, readBody from twisted.web.http_headers import Headers from twisted.web.iweb import IBodyProducer + from zope.interface import implements -from Tribler.Core.Modules.restapi import get_param import Tribler.Core.Utilities.json_util as json from Tribler.Core.Utilities.network_utils import get_random_port from Tribler.Core.version import version_id +from Tribler.Core.Modules.restapi import get_param from Tribler.Test.test_as_server import TestAsServer @@ -60,7 +62,6 @@ def setUpPreSession(self): super(AbstractBaseApiTest, self).setUpPreSession() self.config.set_http_api_enabled(True) self.config.set_http_api_retry_port(True) - self.config.set_megacache_enabled(True) self.config.set_tunnel_community_enabled(False) # Make sure we select a random port for the HTTP API diff --git a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py index f9df2689c05..d3de166dd03 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import os from binascii import hexlify, unhexlify from urllib import pathname2url @@ -20,8 +22,6 @@ class TestDownloadsEndpoint(AbstractApiTest): def setUpPreSession(self): super(TestDownloadsEndpoint, self).setUpPreSession() self.config.set_libtorrent_enabled(True) - self.config.set_megacache_enabled(True) - self.config.set_torrent_store_enabled(True) @trial_timeout(10) def test_get_downloads_no_downloads(self): @@ -483,18 +483,17 @@ def test_export_download(self): Testing whether the API returns the contents of the torrent file if a download is exported """ video_tdef, _ = self.create_local_torrent(os.path.join(TESTS_DATA_DIR, 'video.avi')) - self.session.start_download_from_tdef(video_tdef, DownloadStartupConfig()) - - with open(os.path.join(TESTS_DATA_DIR, 'bak_single.torrent')) as torrent_file: - raw_data = torrent_file.read() - self.session.get_collected_torrent = lambda _: raw_data + download = self.session.start_download_from_tdef(video_tdef, DownloadStartupConfig()) def verify_exported_data(result): - self.assertEqual(raw_data, result) + self.assertTrue(result) - self.should_check_equality = False - return self.do_request('downloads/%s/torrent' % video_tdef.get_infohash().encode('hex'), - expected_code=200, request_type='GET').addCallback(verify_exported_data) + def on_handle_available(_): + self.should_check_equality = False + return self.do_request('downloads/%s/torrent' % video_tdef.get_infohash().encode('hex'), + expected_code=200, request_type='GET').addCallback(verify_exported_data) + + return download.get_handle().addCallback(on_handle_available) @trial_timeout(10) def test_get_files_unknown_download(self): @@ -578,7 +577,7 @@ def verify_download(_): self.assertGreaterEqual(len(self.session.get_downloads()), 1) post_data = {'uri': 'file:%s' % os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')} - expected_json = {'started': True, 'infohash': '02c146a1a3ffd96856a0319d1832cf70989e5a47'} + expected_json = {'started': True, 'infohash': 'ec98c89912a98ce6561d3d47a77e35ee5388fb88'} return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, expected_json=expected_json).addCallback(verify_download) diff --git a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py index 6ea8d2d3e91..fc08e97a5b8 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_events_endpoint.py @@ -73,39 +73,14 @@ def open_events_socket(self, _): def close_connections(self): return self.connection_pool.closeCachedConnections() - @trial_timeout(20) - def test_search_results(self): - """ - Testing whether the event endpoint returns search results when we have search results available - """ - def verify_search_results(results): - self.assertEqual(len(results), 2) - - self.messages_to_wait_for = 2 - - def send_notifications(_): - self.session.lm.api_manager.root_endpoint.events_endpoint.start_new_query() - - results_dict = {"keywords": ["test"], "result_list": [('a',) * 10]} - self.session.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - self.session.notifier.notify(SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - - self.socket_open_deferred.addCallback(send_notifications) - - return self.events_deferred.addCallback(verify_search_results) - @trial_timeout(20) def test_events(self): """ Testing whether various events are coming through the events endpoints """ - self.messages_to_wait_for = 21 + self.messages_to_wait_for = 19 def send_notifications(_): - self.session.lm.api_manager.root_endpoint.events_endpoint.start_new_query() - results_dict = {"keywords": ["test"], "result_list": [('a',) * 10]} - self.session.notifier.notify(SIGNAL_TORRENT, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) - self.session.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_SEARCH_RESULTS, None, results_dict) self.session.notifier.notify(NTFY_UPGRADER, NTFY_STARTED, None, None) self.session.notifier.notify(NTFY_UPGRADER_TICK, NTFY_STARTED, None, None) self.session.notifier.notify(NTFY_UPGRADER, NTFY_FINISHED, None, None) @@ -129,29 +104,3 @@ def send_notifications(_): self.socket_open_deferred.addCallback(send_notifications) return self.events_deferred - - @trial_timeout(20) - def test_family_filter_search(self): - """ - Testing the family filter when searching for torrents and channels - """ - self.messages_to_wait_for = 2 - - def send_searches(_): - events_endpoint = self.session.lm.api_manager.root_endpoint.events_endpoint - - channels = [['a', ] * 10, ['a', ] * 10] - channels[0][2] = 'badterm' - events_endpoint.on_search_results_channels(None, None, None, {"keywords": ["test"], - "result_list": channels}) - self.assertEqual(len(events_endpoint.channel_cids_sent), 1) - - torrents = [['a', ] * 10, ['a', ] * 10] - torrents[0][4] = 'xxx' - events_endpoint.on_search_results_torrents(None, None, None, {"keywords": ["test"], - "result_list": torrents}) - self.assertEqual(len(events_endpoint.infohashes_sent), 1) - - self.socket_open_deferred.addCallback(send_searches) - - return self.events_deferred diff --git a/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py index a4c7cda11c7..3a370e2cdd4 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_market_endpoint.py @@ -4,6 +4,11 @@ from twisted.internet.defer import inlineCallbacks, succeed +from Tribler.Core.Modules.restapi.market import BaseMarketEndpoint +from Tribler.Core.Modules.wallet.dummy_wallet import DummyWallet1, DummyWallet2 +from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.Core.base_test import MockObject +from Tribler.Test.tools import trial_timeout from Tribler.community.market.community import MarketCommunity from Tribler.community.market.core.assetamount import AssetAmount from Tribler.community.market.core.assetpair import AssetPair @@ -15,11 +20,6 @@ from Tribler.community.market.core.timestamp import Timestamp from Tribler.community.market.core.trade import Trade from Tribler.community.market.core.wallet_address import WalletAddress -from Tribler.Core.Modules.restapi.market import BaseMarketEndpoint -from Tribler.Core.Modules.wallet.dummy_wallet import DummyWallet1, DummyWallet2 -from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.tools import trial_timeout from Tribler.pyipv8.ipv8.test.mocking.ipv8 import MockIPv8 diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index 1d3da8887f4..a85dd73b76c 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -1,3 +1,5 @@ +from __future__ import absolute_import + import json from binascii import hexlify @@ -26,7 +28,7 @@ def setUp(self): # Add a few channels with db_session: for ind in xrange(10): - self.session.lm.mds.Metadata._my_key = default_eccrypto.generate_key('low') + self.session.lm.mds.Metadata._my_key = default_eccrypto.generate_key('curve25519') _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) for torrent_ind in xrange(5): _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=random_infohash()) @@ -96,7 +98,7 @@ def test_subscribe(self): """ self.should_check_equality = False post_params = {'subscribe': '1'} - channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()) + channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) return self.do_request('metadata/channels/%s' % channel_pk, expected_code=200, request_type='POST', post_data=post_params) @@ -112,7 +114,7 @@ def on_response(response): self.assertEqual(len(json_dict['torrents']), 5) self.should_check_equality = False - channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()) + channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) return self.do_request('metadata/channels/%s/torrents' % channel_pk, expected_code=200).addCallback(on_response) @@ -188,72 +190,17 @@ def tearDown(self): @trial_timeout(20) @inlineCallbacks def test_check_torrent_health(self): - """ - Test the endpoint to fetch the health of a torrent - """ - torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu-torrent.iso', [['file1.txt', 42]], - ('udp://localhost:%s/announce' % self.udp_port, - 'http://localhost:%s/announce' % self.http_port), time.time()) - - url = 'metadata/torrents/%s/health?timeout=10&refresh=1' % hexlify(b'a' * 20) - - self.should_check_equality = False - yield self.do_request(url, expected_code=400, request_type='GET') # No torrent checker - - def call_cb(infohash, callback, **_): - callback({"seeders": 1, "leechers": 2}) - - # Initialize the torrent checker - self.session.lm.torrent_checker = TorrentChecker(self.session) - self.session.lm.torrent_checker.initialize() - self.session.lm.ltmgr = MockObject() - self.session.lm.ltmgr.get_metainfo = call_cb - - yield self.do_request('torrents/%s/health' % ('f' * 40), expected_code=404, request_type='GET') - - def verify_response_no_trackers(response): - json_response = json.loads(response) - self.assertTrue('DHT' in json_response['health']) - - def verify_response_with_trackers(response): - hex_as = hexlify(b'a' * 20) - json_response = json.loads(response) - expected_dict = {u"health": - {u"DHT": - {u"leechers": 2, u"seeders": 1, u"infohash": hex_as}, - u"udp://localhost:%s" % self.udp_port: - {u"leechers": 20, u"seeders": 10, u"infohash": hex_as}, - u"http://localhost:%s/announce" % self.http_port: - {u"leechers": 30, u"seeders": 20, u"infohash": hex_as}}} - self.assertDictEqual(json_response, expected_dict) - - yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) - - self.udp_tracker.start() - self.udp_tracker.tracker_info.add_info_about_infohash('a' * 20, 10, 20) - - self.http_tracker.start() - self.http_tracker.tracker_info.add_info_about_infohash('a' * 20, 20, 30) - - yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_with_trackers) - - @trial_timeout(20) - @inlineCallbacks - def test_check_torrent_health_chant(self): """ Test the endpoint to fetch the health of a chant-managed, infohash-only torrent """ infohash = 'a' * 20 tracker_url = 'udp://localhost:%s/announce' % self.udp_port - - meta_info = {"info": {"name": "my_torrent", "piece length": 42, - "root hash": infohash, "files": [], - "url-list": tracker_url}} - tdef = TorrentDef.load_from_dict(meta_info) + self.udp_tracker.tracker_info.add_info_about_infohash(infohash, 12, 11, 1) with db_session: - self.session.lm.mds.TorrentMetadata(infohash=tdef.infohash, + tracker_state = self.session.lm.mds.TrackerState(url=tracker_url) + torrent_state = self.session.lm.mds.TorrentState(trackers=tracker_state, infohash=infohash) + self.session.lm.mds.TorrentMetadata(infohash=infohash, title='ubuntu-torrent.iso', size=42, tracker_info=tracker_url, diff --git a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py index 14199ad2cf4..fc9c01815fe 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py @@ -1,7 +1,10 @@ +from __future__ import absolute_import + import json from binascii import hexlify from pony.orm import db_session +from six.moves import xrange from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW, TODELETE diff --git a/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py index 8e6f0c1ed37..57457ce3cc1 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py @@ -18,9 +18,8 @@ def verify_settings(self, settings): """ Verify that the expected sections are present. """ - check_section = ['libtorrent', 'mainline_dht', 'torrent_store', 'general', 'torrent_checking', - 'allchannel_community', 'tunnel_community', 'http_api', 'torrent_collecting', 'dispersy', - 'trustchain', 'watch_folder', 'search_community', 'metadata'] + check_section = ['libtorrent', 'mainline_dht', 'general', 'torrent_checking', + 'tunnel_community', 'http_api', 'trustchain', 'watch_folder'] settings_json = json.loads(settings) self.assertTrue(settings_json['settings']) diff --git a/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py index 59a7de4bac9..b10e9a9b030 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py @@ -28,33 +28,17 @@ def tearDown(self): yield self.mock_ipv8.unload() yield super(TestStatisticsEndpoint, self).tearDown() - def setUpPreSession(self): - super(TestStatisticsEndpoint, self).setUpPreSession() - self.config.set_dispersy_enabled(True) - self.config.set_torrent_collecting_enabled(True) - @trial_timeout(10) def test_get_tribler_statistics(self): """ Testing whether the API returns a correct Tribler statistics dictionary when requested """ def verify_dict(data): - self.assertTrue(json.loads(data)["tribler_statistics"]) + self.assertIn("tribler_statistics", json.loads(data)) self.should_check_equality = False return self.do_request('statistics/tribler', expected_code=200).addCallback(verify_dict) - @trial_timeout(10) - def test_get_dispersy_statistics(self): - """ - Testing whether the API returns a correct Dispersy statistics dictionary when requested - """ - def verify_dict(data): - self.assertTrue(json.loads(data)["dispersy_statistics"]) - - self.should_check_equality = False - return self.do_request('statistics/dispersy', expected_code=200).addCallback(verify_dict) - @trial_timeout(10) def test_get_ipv8_statistics(self): """ @@ -78,16 +62,3 @@ def verify_dict(data): self.should_check_equality = False return self.do_request('statistics/ipv8', expected_code=200).addCallback(verify_dict) - - @trial_timeout(10) - def test_get_community_statistics(self): - """ - Testing whether the API returns a correct community statistics dictionary when requested - """ - def verify_dict(data): - json_data = json.loads(data) - self.assertTrue(json_data["dispersy_community_statistics"]) - self.assertTrue(json_data["ipv8_overlay_statistics"]) - - self.should_check_equality = False - return self.do_request('statistics/communities', expected_code=200).addCallback(verify_dict) diff --git a/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py new file mode 100644 index 00000000000..c7c19e6b1f0 --- /dev/null +++ b/Tribler/Test/Core/Modules/RestApi/test_torrentinfo_endpoint.py @@ -0,0 +1,105 @@ +from __future__ import absolute_import + +import os +import shutil +from binascii import hexlify +from urllib import pathname2url, quote_plus + +from twisted.internet.defer import inlineCallbacks + +import Tribler.Core.Utilities.json_util as json +from Tribler.Core.TorrentDef import TorrentDef +from Tribler.Core.Utilities.network_utils import get_random_port +from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.Core.base_test import MockObject +from Tribler.Test.common import TORRENT_UBUNTU_FILE, UBUNTU_1504_INFOHASH +from Tribler.Test.test_as_server import TESTS_DATA_DIR, TESTS_DIR +from Tribler.Test.tools import trial_timeout + +SAMPLE_CHANNEL_FILES_DIR = os.path.join(TESTS_DIR, "Core", "data", "sample_channel") + + +class TestTorrentInfoEndpoint(AbstractApiTest): + + @inlineCallbacks + def test_get_torrentinfo(self): + """ + Testing whether the API returns a correct dictionary with torrent info. + """ + # We intentionally put the file path in a folder with a: + # - "+" which is a reserved URI character + # - "\u0191" which is a unicode character + files_path = os.path.join(self.session_base_dir, u'http_torrent_+\u0191files') + os.mkdir(files_path) + shutil.copyfile(TORRENT_UBUNTU_FILE, os.path.join(files_path, 'ubuntu.torrent')) + + file_server_port = get_random_port() + self.setUpFileServer(file_server_port, files_path) + + def verify_valid_dict(data): + metainfo_dict = json.loads(data, encoding='latin_1') + self.assertTrue('metainfo' in metainfo_dict) + self.assertTrue('info' in metainfo_dict['metainfo']) + + self.should_check_equality = False + yield self.do_request('torrentinfo', expected_code=400) + yield self.do_request('torrentinfo?uri=def', expected_code=400) + + path = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "bak_single.torrent")).encode('utf-8') + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) + + # Corrupt file + path = "file:" + pathname2url(os.path.join(TESTS_DATA_DIR, "test_rss.xml")).encode('utf-8') + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=500) + + path = "http://localhost:%d/ubuntu.torrent" % file_server_port + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) + + def get_metainfo(infohash, callback, **_): + with open(os.path.join(TESTS_DATA_DIR, "bak_single.torrent"), mode='rb') as torrent_file: + torrent_data = torrent_file.read() + tdef = TorrentDef.load_from_memory(torrent_data) + callback(tdef.get_metainfo()) + + def get_metainfo_timeout(*args, **kwargs): + timeout_cb = kwargs.get('timeout_callback') + timeout_cb('a' * 20) + + path = 'magnet:?xt=urn:btih:%s&dn=%s' % (hexlify(UBUNTU_1504_INFOHASH), quote_plus('test torrent')) + self.session.lm.ltmgr = MockObject() + self.session.lm.ltmgr.get_metainfo = get_metainfo + self.session.lm.ltmgr.shutdown = lambda: None + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) + + # mdblob file + path_blob = "file:" + pathname2url(os.path.join(SAMPLE_CHANNEL_FILES_DIR, "channel.mdblob")).encode('utf-8') + yield self.do_request('torrentinfo?uri=%s' % path_blob, expected_code=200).addCallback(verify_valid_dict) + + path = 'magnet:?xt=urn:ed2k:354B15E68FB8F36D7CD88FF94116CDC1' # No infohash + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=400) + + path = 'magnet:?xt=urn:btih:%s&dn=%s' % ('a' * 40, quote_plus('test torrent')) + self.session.lm.ltmgr.get_metainfo = get_metainfo_timeout + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=408) + + self.session.lm.ltmgr.get_metainfo = get_metainfo + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=200).addCallback(verify_valid_dict) + + path = 'http://fdsafksdlafdslkdksdlfjs9fsafasdf7lkdzz32.n38/324.torrent' + yield self.do_request('torrentinfo?uri=%s' % path, expected_code=500) + + @trial_timeout(10) + def test_on_got_invalid_metainfo(self): + """ + Test whether the right operations happen when we receive an invalid metainfo object + """ + def get_metainfo(infohash, callback, **_): + callback("abcd") + + self.session.lm.ltmgr = MockObject() + self.session.lm.ltmgr.get_metainfo = get_metainfo + self.session.lm.ltmgr.shutdown = lambda: None + path = 'magnet:?xt=urn:btih:%s&dn=%s' % (hexlify(UBUNTU_1504_INFOHASH), quote_plus('test torrent')) + + self.should_check_equality = False + return self.do_request('torrentinfo?uri=%s' % path, expected_code=500) diff --git a/Tribler/Test/Core/Modules/RestApi/test_util.py b/Tribler/Test/Core/Modules/RestApi/test_util.py index 3e30618faf0..8c4fd27b223 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_util.py +++ b/Tribler/Test/Core/Modules/RestApi/test_util.py @@ -1,8 +1,8 @@ # -*- coding:utf-8 -*- +from __future__ import absolute_import from Tribler.Core.Config.tribler_config import TriblerConfig -from Tribler.Core.Modules.restapi.util import convert_search_torrent_to_json, convert_db_channel_to_json,\ - get_parameter, fix_unicode_array, fix_unicode_dict +from Tribler.Core.Modules.restapi.util import get_parameter, fix_unicode_array, fix_unicode_dict from Tribler.Core.Session import Session from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject @@ -22,56 +22,6 @@ def setUp(self): def tearDown(self): TriblerCoreTest.tearDown(self) - def test_convert_torrent_to_json_dict(self): - """ - Test whether the conversion from remote torrent dict to json works - """ - input = {'torrent_id': 42, 'infohash': 'a', 'name': 'test torrent', 'length': 43, - 'category': 'other', 'num_seeders': 1, 'num_leechers': 2} - output = {'id': 42, 'infohash': 'a'.encode('hex'), 'name': 'test torrent', 'size': 43, 'category': 'other', - 'num_seeders': 1, 'num_leechers': 2, 'last_tracker_check': 0} - self.assertEqual(convert_search_torrent_to_json(input), output) - - input['name'] = None - output['name'] = 'Unnamed torrent' - self.assertEqual(convert_search_torrent_to_json(input), output) - - input['name'] = ' \t\n\n\t \t' - output['name'] = 'Unnamed torrent' - self.assertEqual(convert_search_torrent_to_json(input), output) - - def test_convert_torrent_to_json_tuple(self): - """ - Test whether the conversion from db torrent tuple to json works - """ - input_tuple = (1, '2', 'abc', 4, 5, 6, 7, 8, 0, 0.123) - output = {'category': 5, - 'commit_status': 0, - 'date': 0, - 'dispersy_cid': '', - 'health': u'Seeds found', - 'id': 1, - 'infohash': '32', - 'last_tracker_check': 8, - 'name': 'abc', - 'num_leechers': 7, - 'num_seeders': 6, - 'public_key': '', - 'relevance_score': 0.123, - 'size': 4, - 'subscribed': '', - 'type': 'torrent', - 'votes': 0} - self.assertEqual(convert_search_torrent_to_json(input_tuple), output) - - input_tuple = (1, '2', None, 4, 5, 6, 7, 8, 0, 0.123) - output['name'] = 'Unnamed torrent' - self.assertEqual(convert_search_torrent_to_json(input_tuple), output) - - input_tuple = (1, '2', ' \t\n\n\t \t', 4, 5, 6, 7, 8, 0, 0.123) - output['name'] = 'Unnamed torrent' - self.assertEqual(convert_search_torrent_to_json(input_tuple), output) - def test_get_parameter(self): """ Testing the get_parameters method in REST API util class @@ -79,15 +29,6 @@ def test_get_parameter(self): self.assertEqual(42, get_parameter({'test': [42]}, 'test')) self.assertEqual(None, get_parameter({}, 'test')) - def test_convert_db_channel_to_json(self): - """ - Test whether the conversion from a db channel tuple to json works - """ - input_tuple = (1, 'aaaa'.decode('hex'), 'test', 'desc', 42, 43, 44, 2, 1234, 0.123) - output = {'id': 1, 'dispersy_cid': 'aaaa', 'name': 'test', 'description': 'desc', 'torrents': 42, 'votes': 43, - 'spam': 44, 'subscribed': True, 'modified': 1234, 'relevance_score': 0.123} - self.assertEqual(convert_db_channel_to_json(input_tuple, include_rel_score=True), output) - def test_fix_unicode_array(self): """ Testing the fix of a unicode array diff --git a/Tribler/Test/Core/Modules/test_tracker_manager.py b/Tribler/Test/Core/Modules/test_tracker_manager.py index 5be1b9cdece..055957dbd3a 100644 --- a/Tribler/Test/Core/Modules/test_tracker_manager.py +++ b/Tribler/Test/Core/Modules/test_tracker_manager.py @@ -1,33 +1,17 @@ -from twisted.internet.defer import inlineCallbacks +from __future__ import absolute_import -from Tribler.Core.Config.tribler_config import TriblerConfig -from Tribler.Core.Modules.tracker_manager import TrackerManager -from Tribler.Core.Session import Session -from Tribler.Test.Core.base_test import TriblerCoreTest +from Tribler.Test.test_as_server import TestAsServer -class TestTrackerManager(TriblerCoreTest): +class TestTrackerManager(TestAsServer): def setUpPreSession(self): - self.config = TriblerConfig() - self.config.set_state_dir(self.getStateDir()) - - @inlineCallbacks - def setUp(self): - yield super(TestTrackerManager, self).setUp() - - self.setUpPreSession() - self.session = Session(self.config) - self.session.start_database() - self.tracker_manager = TrackerManager(self.session) - - @inlineCallbacks - def tearDown(self): - if self.session is not None: - yield self.session.shutdown() - assert self.session.has_shutdown() - self.session = None - yield super(TestTrackerManager, self).tearDown() + super(TestTrackerManager, self).setUpPreSession() + self.config.set_chant_enabled(True) + + @property + def tracker_manager(self): + return self.session.lm.tracker_manager def test_add_tracker(self): """ diff --git a/Tribler/Test/Core/Modules/test_watch_folder.py b/Tribler/Test/Core/Modules/test_watch_folder.py index e6074029d7e..64222ff0210 100644 --- a/Tribler/Test/Core/Modules/test_watch_folder.py +++ b/Tribler/Test/Core/Modules/test_watch_folder.py @@ -1,7 +1,9 @@ +from __future__ import absolute_import + import os import shutil -from Tribler.Test.common import TORRENT_UBUNTU_FILE, TESTS_DATA_DIR +from Tribler.Test.common import TESTS_DATA_DIR, TORRENT_UBUNTU_FILE from Tribler.Test.test_as_server import TestAsServer @@ -11,7 +13,6 @@ def setUpPreSession(self): super(TestWatchFolder, self).setUpPreSession() self.config.set_libtorrent_enabled(True) self.config.set_watch_folder_enabled(True) - self.config.set_dispersy_enabled(True) self.watch_dir = os.path.join(self.session_base_dir, 'watch') os.mkdir(self.watch_dir) diff --git a/Tribler/Test/Core/TFTP/__init__.py b/Tribler/Test/Core/TFTP/__init__.py deleted file mode 100644 index ca1d8f0d09a..00000000000 --- a/Tribler/Test/Core/TFTP/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -This package contains tests for the TFTP mechanism in Tribler. -""" diff --git a/Tribler/Test/Core/TFTP/test_tftp_handler.py b/Tribler/Test/Core/TFTP/test_tftp_handler.py deleted file mode 100644 index 40809a81f9a..00000000000 --- a/Tribler/Test/Core/TFTP/test_tftp_handler.py +++ /dev/null @@ -1,227 +0,0 @@ -from nose.tools import raises -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.TFTP.exception import FileNotFound -from Tribler.Core.TFTP.handler import TftpHandler, METADATA_PREFIX -from Tribler.Core.TFTP.packet import OPCODE_OACK, OPCODE_ERROR, OPCODE_RRQ -from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject - - -class TestTFTPHandler(TriblerCoreTest): - """ - This class contains tests for the TFTP handler class. - """ - - @inlineCallbacks - def setUp(self): - yield TriblerCoreTest.setUp(self) - self.handler = TftpHandler(None, None, None) - - @inlineCallbacks - def tearDown(self): - self.handler.shutdown_task_manager() - yield TriblerCoreTest.tearDown(self) - - def test_download_file_not_running(self): - """ - Testing whether we do nothing if we are not running a session - """ - def mocked_add_new_session(_): - raise RuntimeError("_add_new_session not be called") - - self.handler._add_new_session = mocked_add_new_session - self.handler.download_file("test", "127.0.0.1", 1234) - - def test_check_session_timeout(self): - """ - Testing whether we fail if we exceed our maximum amount of retries - """ - mock_session = MockObject() - mock_session.retries = 2 - mock_session.timeout = 1 - mock_session.last_contact_time = 2 - self.handler._max_retries = 1 - self.assertTrue(self.handler._check_session_timeout(mock_session)) - - def test_schedule_callback_processing(self): - """ - Testing whether scheduling a TFTP callback works correctly - """ - self.assertFalse(self.handler.is_pending_task_active("tftp_process_callback")) - self.handler._callback_scheduled = True - self.handler._schedule_callback_processing() - self.assertFalse(self.handler.is_pending_task_active("tftp_process_callback")) - - def test_cleanup_session(self): - """ - Testing whether a tftp session is correctly cleaned up - """ - self.handler._session_id_dict["c"] = 1 - self.handler._session_dict = {"abc": "test"} - self.handler._cleanup_session("abc") - self.assertFalse('c' in self.handler._session_id_dict) - - def test_data_came_in(self): - """ - Testing whether we do nothing when data comes in and the handler is not running - """ - def mocked_process_packet(_dummy1, _dummy2): - raise RuntimeError("_process_packet may not be called") - - self.handler._process_packet = mocked_process_packet - self.handler._is_running = False - self.handler.data_came_in(None, None) - - def test_data_came_in_invalid_candidate(self): - """ - Testing whether we do nothing when data comes in from an invalid candidate - """ - setattr(self.handler, "_process_packet", - lambda x, y: (_ for _ in ()).throw(RuntimeError("_process_packet may not be called"))) - self.handler.data_came_in(('182.30.65.219', 0), None) - - def test_handle_new_request_no_metadata(self): - """ - When the metadata_store from LaunchManyCore is not available, return - from the function rather than trying to load the metadata. - - :return: - """ - # Make sure the packet appears to have the correct attributes - fake_packet = {"opcode": OPCODE_RRQ, - "file_name": METADATA_PREFIX + "abc", - "options": {"blksize": 1, - "timeout": 1}, - "session_id": 1} - self.handler._load_metadata = lambda _: self.fail("This line should not be called") - - def test_function(): - test_function.is_called = True - return False - test_function.is_called = False - self.handler.session = MockObject() - self.handler.session.config = MockObject() - self.handler.session.config.get_metadata_enabled = test_function - - self.handler._handle_new_request("123", "456", fake_packet) - self.assertTrue(test_function.is_called) - - def test_handle_new_request_no_torrent_store(self): - """ - When the torrent_store from LaunchManyCore is not available, return - from the function rather than trying to load the metadata. - - :return: - """ - self.handler.session = MockObject() - # Make sure the packet appears to have the correct attributes - fake_packet = {"opcode": OPCODE_RRQ, - "file_name": "abc", - "options": {"blksize": 1, - "timeout": 1}, - "session_id": 1} - self.handler._load_metadata = lambda _: self.fail("This line should not be called") - - def test_function(): - test_function.is_called = True - return False - test_function.is_called = False - self.handler.session.config = MockObject() - self.handler.session.config.get_torrent_store_enabled = test_function - - self.handler._handle_new_request("123", "456", fake_packet) - self.assertTrue(test_function.is_called) - - @raises(FileNotFound) - def test_load_metadata_not_found(self): - """ - Testing whether a FileNotFound exception is raised when metadata cannot be found - """ - self.handler.session = MockObject() - self.handler.session.lm = MockObject() - self.handler.session.lm.metadata_store = MockObject() - self.handler.session.lm.metadata_store.get = lambda _: None - self.handler._load_metadata("abc") - - @raises(FileNotFound) - def test_load_torrent_not_found(self): - """ - Testing whether a FileNotFound exception is raised when a torrent cannot be found - """ - self.handler.session = MockObject() - self.handler.session.lm = MockObject() - self.handler.session.lm.torrent_store = MockObject() - self.handler.session.lm.torrent_store.get = lambda _: None - self.handler._load_torrent("abc") - - def test_handle_packet_as_receiver(self): - """ - Testing the handle_packet_as_receiver method - """ - def mocked_handle_error(_dummy1, _dummy2, error_msg=None): - mocked_handle_error.called = True - - mocked_handle_error.called = False - self.handler._handle_error = mocked_handle_error - - mock_session = MockObject() - mock_session.last_received_packet = None - mock_session.block_size = 42 - mock_session.timeout = 44 - packet = {'opcode': OPCODE_OACK, 'options': {'blksize': 43, 'timeout': 45}} - self.handler._handle_packet_as_receiver(mock_session, packet) - self.assertTrue(mocked_handle_error.called) - - mocked_handle_error.called = False - packet['options']['blksize'] = 42 - self.handler._handle_packet_as_receiver(mock_session, packet) - self.assertTrue(mocked_handle_error.called) - - mock_session.last_received_packet = True - mocked_handle_error.called = False - self.handler._handle_packet_as_receiver(mock_session, packet) - self.assertTrue(mocked_handle_error.called) - - packet['options']['timeout'] = 44 - packet['opcode'] = OPCODE_ERROR - mocked_handle_error.called = False - self.handler._handle_packet_as_receiver(mock_session, packet) - self.assertTrue(mocked_handle_error.called) - - def test_handle_packet_as_sender(self): - """ - Testing the handle_packet_as_sender method - """ - def mocked_handle_error(_dummy1, _dummy2, error_msg=None): - mocked_handle_error.called = True - - mocked_handle_error.called = False - self.handler._handle_error = mocked_handle_error - - packet = {'opcode': OPCODE_ERROR} - self.handler._handle_packet_as_sender(None, packet) - self.assertTrue(mocked_handle_error.called) - - def test_handle_error(self): - """ - Testing the error handling of a tftp handler - """ - mock_session = MockObject() - mock_session.is_failed = False - self.handler._send_error_packet = lambda _dummy1, _dummy2, _dummy3: None - self.handler._handle_error(mock_session, None) - self.assertTrue(mock_session.is_failed) - - def test_send_error_packet(self): - """ - Testing whether a correct error message is sent in the tftp handler - """ - def mocked_send_packet(_, packet): - self.assertEqual(packet['session_id'], 42) - self.assertEqual(packet['error_code'], 43) - self.assertEqual(packet['error_msg'], "test") - - self.handler._send_packet = mocked_send_packet - mock_session = MockObject() - mock_session.session_id = 42 - self.handler._send_error_packet(mock_session, 43, "test") diff --git a/Tribler/Test/Core/TFTP/test_tftp_packet.py b/Tribler/Test/Core/TFTP/test_tftp_packet.py deleted file mode 100644 index ab1cd71d054..00000000000 --- a/Tribler/Test/Core/TFTP/test_tftp_packet.py +++ /dev/null @@ -1,104 +0,0 @@ -from nose.tools import raises - -from Tribler.Core.TFTP.exception import InvalidStringException, InvalidPacketException -from Tribler.Core.TFTP.packet import _get_string, _decode_options, _decode_data, _decode_ack, _decode_error, \ - decode_packet, OPCODE_ERROR, encode_packet -from Tribler.Test.Core.base_test import TriblerCoreTest - - -class TestTFTPPacket(TriblerCoreTest): - """ - This class contains tests for the TFTP packet class. - """ - - @raises(InvalidStringException) - def test_get_string_no_end(self): - """ - Testing whether the get_string method raises InvalidStringException when no zero terminator is found - """ - _get_string("", 0) - - @raises(InvalidPacketException) - def test_decode_options_no_option(self): - """ - Testing whether decoding the options raises InvalidPacketException if no options are found - """ - _decode_options({}, "\0a\0", 0) - - @raises(InvalidPacketException) - def test_decode_options_no_value(self): - """ - Testing whether decoding the options raises InvalidPacketException if no value is found - """ - _decode_options({}, "b\0\0", 0) - - @raises(InvalidPacketException) - def test_decode_options_unknown(self): - """ - Testing whether decoding the options raises InvalidPacketException if an invalid option is found - """ - _decode_options({}, "b\0a\0", 0) - - @raises(InvalidPacketException) - def test_decode_options_invalid(self): - """ - Testing whether decoding the options raises InvalidPacketException if an invalid option is found - """ - _decode_options({}, "blksize\0a\0", 0) - - @raises(InvalidPacketException) - def test_decode_data(self): - """ - Testing whether an InvalidPacketException is raised when our incoming data is too small - """ - _decode_data(None, "aa", 42) - - @raises(InvalidPacketException) - def test_decode_ack(self): - """ - Testing whether an InvalidPacketException is raised when our incoming ack has an invalid size - """ - _decode_ack(None, "aa", 42) - - @raises(InvalidPacketException) - def test_decode_error_too_small(self): - """ - Testing whether an InvalidPacketException is raised when our incoming error has an invalid size - """ - _decode_error(None, "aa", 42) - - @raises(InvalidPacketException) - def test_decode_error_no_message(self): - """ - Testing whether an InvalidPacketException is raised when our incoming error has an empty message - """ - _decode_error({}, "aa\0", 0) - - @raises(InvalidPacketException) - def test_decode_error_invalid_pkg(self): - """ - Testing whether an InvalidPacketException is raised when our incoming error has an invalid structure - """ - _decode_error({}, "aaa\0\0", 0) - - @raises(InvalidPacketException) - def test_decode_packet_too_small(self): - """ - Testing whether an InvalidPacketException is raised when our incoming packet is too small - """ - decode_packet("aaa") - - @raises(InvalidPacketException) - def test_decode_packet_opcode(self): - """ - Testing whether an InvalidPacketException is raised when our incoming packet contains an invalid opcode - """ - decode_packet("aaaaaaaaaa") - - def test_encode_packet_error(self): - """ - Testing whether the encoding of an error packet is correct - """ - encoded = encode_packet({'opcode': OPCODE_ERROR, 'session_id': 123, 'error_code': 1, 'error_msg': 'hi'}) - self.assertEqual(encoded[-3], 'h') - self.assertEqual(encoded[-2], 'i') diff --git a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py index 12591d8fe30..bca50c389ac 100644 --- a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py +++ b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py @@ -1,17 +1,18 @@ +from __future__ import absolute_import + import socket import time -from Tribler.Test.tools import trial_timeout +from pony.orm import db_session + from twisted.internet.defer import Deferred, inlineCallbacks -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler -from Tribler.Core.Category.Category import Category from Tribler.Core.Modules.tracker_manager import TrackerManager from Tribler.Core.TorrentChecker.session import HttpTrackerSession, UdpSocketManager from Tribler.Core.TorrentChecker.torrent_checker import TorrentChecker -from Tribler.Core.simpledefs import NTFY_TORRENTS from Tribler.Test.Core.base_test import MockObject from Tribler.Test.test_as_server import TestAsServer +from Tribler.Test.tools import trial_timeout from Tribler.community.popularity.repository import TYPE_TORRENT_HEALTH @@ -22,20 +23,17 @@ class TestTorrentChecker(TestAsServer): def setUpPreSession(self): super(TestTorrentChecker, self).setUpPreSession() - self.config.set_megacache_enabled(True) + self.config.set_chant_enabled(True) @inlineCallbacks def setUp(self): yield super(TestTorrentChecker, self).setUp() - self.session.lm.torrent_db = TorrentDBHandler(self.session) self.session.lm.torrent_checker = TorrentChecker(self.session) self.session.lm.tracker_manager = TrackerManager(self.session) self.session.lm.popularity_community = MockObject() self.torrent_checker = self.session.lm.torrent_checker - self.torrent_checker._torrent_db = self.session.open_dbhandler(NTFY_TORRENTS) - self.torrent_checker._torrent_db.category = Category() self.torrent_checker.listen_on_udp = lambda: None def test_initialize(self): @@ -43,7 +41,6 @@ def test_initialize(self): Test the initialization of the torrent checker """ self.torrent_checker.initialize() - self.assertIsNotNone(self.torrent_checker._torrent_db) self.assertTrue(self.torrent_checker.is_pending_task_active("torrent_checker_tracker_selection")) def test_create_socket_or_schedule_fail(self): @@ -72,10 +69,8 @@ def test_add_gui_request_no_trackers(self): Test whether adding a request to fetch health of a trackerless torrent fails """ test_deferred = Deferred() - self.torrent_checker._torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu.iso', [['a.test', 1234]], [], 5) - - # Remove the DHT tracker - self.torrent_checker._torrent_db._db.execute_write("DELETE FROM TorrentTrackerMapping",) + with db_session: + self.session.lm.mds.TorrentState(infohash='a' * 20) self.torrent_checker.add_gui_request('a' * 20).addErrback(lambda _: test_deferred.callback(None)) return test_deferred @@ -84,9 +79,10 @@ def test_add_gui_request_cached(self): """ Test whether cached results of a torrent are returned when fetching the health of a torrent """ - self.torrent_checker._torrent_db.addExternalTorrentNoDef('a' * 20, 'ubuntu.iso', [['a.test', 1234]], [], 5) - self.torrent_checker._torrent_db.updateTorrentCheckResult( - 1, 'a' * 20, 5, 10, time.time(), time.time(), 'good', 0) + with db_session: + tracker = self.session.lm.mds.TrackerState(url="http://localhost/tracker") + self.session.lm.mds.TorrentState(infohash='a' * 20, seeders=5, leechers=10, trackers={tracker}, + last_check=int(time.time())) def verify_response(result): self.assertTrue('db' in result) @@ -108,8 +104,9 @@ def test_task_select_no_tracker(self): return self.torrent_checker._task_select_tracker() def test_task_select_tracker(self): - self.torrent_checker._torrent_db.addExternalTorrentNoDef( - 'a' * 20, 'ubuntu.iso', [['a.test', 1234]], ['http://google.com/announce'], 5) + with db_session: + tracker = self.session.lm.mds.TrackerState(url="http://localhost/tracker") + self.session.lm.mds.TorrentState(infohash='a' * 20, seeders=5, leechers=10, trackers={tracker}) controlled_session = HttpTrackerSession(None, None, None, None) controlled_session.connect_to_tracker = lambda: Deferred() @@ -128,31 +125,12 @@ def verify_cleanup(_): # Verify whether we successfully cleaned up the session after an error self.assertEqual(len(self.torrent_checker._session_list), 1) - self.torrent_checker._torrent_db.addExternalTorrentNoDef( - 'a' * 20, 'ubuntu.iso', [['a.test', 1234]], ['udp://non123exiszzting456tracker89fle.abc:80/announce'], 5) + with db_session: + tracker = self.session.lm.mds.TrackerState(url="http://localhost/tracker") + self.session.lm.mds.TorrentState(infohash='a' * 20, seeders=5, leechers=10, trackers={tracker}, + last_check=int(time.time())) return self.torrent_checker._task_select_tracker().addCallback(verify_cleanup) - @trial_timeout(30) - def test_tracker_test_invalid_tracker(self): - """ - Test whether we do nothing when tracker URL is invalid - """ - tracker_url = u'udp://non123exiszzting456tracker89fle.abc:80' - bad_tracker_url = u'xyz://non123exiszzting456tracker89fle.abc:80' - - self.torrent_checker._torrent_db.addExternalTorrentNoDef( - 'a' * 20, 'ubuntu.iso', [['a.test', 1234]], [tracker_url], 5) - - # Write invalid url to the database - sql_stmt = u"UPDATE TrackerInfo SET tracker = ? WHERE tracker = ?" - self.session.sqlite_db.execute(sql_stmt, (bad_tracker_url, tracker_url)) - - def verify_response(resp): - self.assertFalse(self.session.lm.tracker_manager.get_tracker_info(bad_tracker_url)) - self.assertIsNone(resp) - - return self.torrent_checker._task_select_tracker().addCallback(verify_response) - @trial_timeout(10) def test_tracker_no_infohashes(self): """ diff --git a/Tribler/Test/Core/Upgrade/test_config_upgrade_70_71.py b/Tribler/Test/Core/Upgrade/test_config_upgrade_70_71.py index f1eecf6aa49..345d9d0aa75 100644 --- a/Tribler/Test/Core/Upgrade/test_config_upgrade_70_71.py +++ b/Tribler/Test/Core/Upgrade/test_config_upgrade_70_71.py @@ -2,13 +2,14 @@ import os import shutil + from six.moves.configparser import RawConfigParser -from Tribler.Core.simpledefs import STATEDIR_DLPSTATE_DIR from configobj import ConfigObj -from Tribler.Core.Config.tribler_config import TriblerConfig, CONFIG_SPEC_PATH +from Tribler.Core.Config.tribler_config import CONFIG_SPEC_PATH, TriblerConfig from Tribler.Core.Upgrade.config_converter import add_libtribler_config, add_tribler_config, convert_config_to_tribler71 +from Tribler.Core.simpledefs import STATEDIR_DLPSTATE_DIR from Tribler.Test.Core.base_test import TriblerCoreTest @@ -40,7 +41,6 @@ def test_read_test_libtribler_conf(self): result_config = add_libtribler_config(new_config, old_config) self.assertEqual(result_config.get_permid_keypair_filename(), "/anon/TriblerDir.gif") self.assertEqual(result_config.get_tunnel_community_socks5_listen_ports(), [1, 2, 3, 4, 5, 6]) - self.assertTrue(result_config.get_metadata_store_dir().endswith("/home/.Tribler/testFile")) self.assertEqual(result_config.get_anon_proxy_settings(), (2, ("127.0.0.1", [5, 4, 3, 2, 1]), '')) self.assertEqual(result_config.get_credit_mining_sources(), ['source1', 'source2']) self.assertEqual(result_config.get_log_dir(), '/a/b/c') @@ -75,7 +75,6 @@ def test_read_test_corr_libtribler_conf(self): self.assertTrue(result_config.get_permid_keypair_filename().endswith("ec.pem")) self.assertTrue(len(result_config.get_tunnel_community_socks5_listen_ports()), 5) - self.assertTrue(result_config.get_metadata_store_dir().endswith("collected_metadata")) self.assertEqual(result_config.get_anon_proxy_settings(), (2, ('127.0.0.1', [-1, -1, -1, -1, -1]), '')) self.assertEqual(result_config.get_credit_mining_sources(), new_config.get_credit_mining_sources()) diff --git a/Tribler/Test/Core/Upgrade/test_db_upgrader.py b/Tribler/Test/Core/Upgrade/test_db_upgrader.py deleted file mode 100644 index a9a369e6993..00000000000 --- a/Tribler/Test/Core/Upgrade/test_db_upgrader.py +++ /dev/null @@ -1,64 +0,0 @@ -import os - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler -from Tribler.Core.CacheDB.db_versions import LATEST_DB_VERSION -from Tribler.Core.Upgrade.db_upgrader import DBUpgrader, VersionNoLongerSupportedError, DatabaseUpgradeError -from Tribler.Core.Utilities.utilities import fix_torrent -from Tribler.Core.leveldbstore import LevelDbStore -from Tribler.Test.Core.Upgrade.upgrade_base import AbstractUpgrader, MockTorrentStore -from Tribler.Test.common import TORRENT_UBUNTU_FILE, TORRENT_UBUNTU_FILE_INFOHASH - - -class TestDBUpgrader(AbstractUpgrader): - - def test_upgrade_from_obsolete_version(self): - """We no longer support DB versions older than 17 (Tribler 6.0)""" - self.copy_and_initialize_upgrade_database('tribler_v12.sdb') - - db_migrator = DBUpgrader(self.session, self.sqlitedb, torrent_store=MockTorrentStore()) - self.assertRaises(VersionNoLongerSupportedError, db_migrator.start_migrate) - - def test_upgrade_17_to_latest(self): - self.copy_and_initialize_upgrade_database('tribler_v17.sdb') - db_migrator = DBUpgrader(self.session, self.sqlitedb, torrent_store=MockTorrentStore()) - db_migrator.start_migrate() - self.assertEqual(self.sqlitedb.version, LATEST_DB_VERSION) - self.assertFalse(os.path.exists(os.path.join(self.session.config.get_torrent_collecting_dir(), 'dir1'))) - - def test_upgrade_17_to_latest_no_dispersy(self): - # upgrade without dispersy DB should not raise an error - self.copy_and_initialize_upgrade_database('tribler_v17.sdb') - os.unlink(os.path.join(self.session.config.get_state_dir(), 'sqlite', 'dispersy.db')) - db_migrator = DBUpgrader(self.session, self.sqlitedb, torrent_store=MockTorrentStore()) - db_migrator.start_migrate() - self.assertEqual(self.sqlitedb.version, LATEST_DB_VERSION) - - # Check whether the torrents in the database are reindexed - results = self.sqlitedb.fetchall("SELECT * FROM FullTextIndex") - self.assertEqual(len(results), 1) - self.assertTrue('test' in results[0][0]) - self.assertTrue('random' in results[0][1]) - self.assertTrue('tribler' in results[0][1]) - self.assertTrue('txt' in results[0][2]) - self.assertTrue('txt' in results[0][2]) - - def test_upgrade_wrong_version(self): - self.copy_and_initialize_upgrade_database('tribler_v17.sdb') - db_migrator = DBUpgrader(self.session, self.sqlitedb, torrent_store=MockTorrentStore()) - db_migrator.db._version = LATEST_DB_VERSION + 1 - self.assertRaises(DatabaseUpgradeError, db_migrator.start_migrate) - - def test_reimport_torrents(self): - self.copy_and_initialize_upgrade_database('tribler_v17.sdb') - self.torrent_store = LevelDbStore(self.session.config.get_torrent_store_dir()) - db_migrator = DBUpgrader(self.session, self.sqlitedb, torrent_store=self.torrent_store) - db_migrator.start_migrate() - - # Import a torrent - self.torrent_store[TORRENT_UBUNTU_FILE_INFOHASH] = fix_torrent(TORRENT_UBUNTU_FILE) - self.torrent_store.flush() - - db_migrator.reimport_torrents() - - torrent_db_handler = TorrentDBHandler(self.session) - self.assertEqual(torrent_db_handler.getTorrentID(TORRENT_UBUNTU_FILE_INFOHASH), 3) diff --git a/Tribler/Test/Core/Upgrade/test_pickle_converter.py b/Tribler/Test/Core/Upgrade/test_pickle_converter.py deleted file mode 100644 index db56d0a6e48..00000000000 --- a/Tribler/Test/Core/Upgrade/test_pickle_converter.py +++ /dev/null @@ -1,67 +0,0 @@ -import os -import pickle - -from Tribler.Core.Config.tribler_config import TriblerConfig, FILENAME as TRIBLER_CONFIG_FILENAME -from Tribler.Core.Upgrade.pickle_converter import PickleConverter -from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject - - -class TestPickleConverter(TriblerCoreTest): - """ - This file contains tests for the converter that converts older pickle files to the .state format. - """ - - def setUp(self): - super(TestPickleConverter, self).setUp() - - self.mock_session = MockObject() - self.mock_session.get_downloads_pstate_dir = lambda: self.session_base_dir - self.mock_session.config = TriblerConfig() - self.mock_session.config.get_state_dir = lambda: self.session_base_dir - - def write_pickle_file(self, content, filename): - pickle_filepath = os.path.join(self.session_base_dir, filename) - pickle.dump(content, open(pickle_filepath, "wb")) - - def test_convert_session_config(self): - old_pickle_dict = {"state_dir": "/", "mainline_dht_port": 1337, "torrent_checking": "false", - "torrent_collecting": "true", "libtorrent": False, "dispersy_port": 1337, - "minport": 1234} - self.write_pickle_file(old_pickle_dict, "sessconfig.pickle") - - PickleConverter(self.mock_session).convert_session_config() - - self.assertTrue(os.path.exists(os.path.join(self.session_base_dir, TRIBLER_CONFIG_FILENAME))) - self.assertFalse(os.path.exists(os.path.join(self.session_base_dir, "sessconfig.pickle"))) - - # Check the content of the config file - config = TriblerConfig.load(config_path=os.path.join(self.session_base_dir, TRIBLER_CONFIG_FILENAME)) - self.assertEqual(config.get_state_dir(), '/') - self.assertEqual(config.get_mainline_dht_port(), 1337) - self.assertEqual(config.get_torrent_checking_enabled(), False) - self.assertEqual(config.get_torrent_collecting_enabled(), True) - self.assertFalse(config.get_libtorrent_enabled()) - self.assertEqual(config.get_dispersy_port(), 1337) - self.assertEqual(config.get_libtorrent_port(), 1234) - - def test_convert_download_checkpoints(self): - with open(os.path.join(self.session_base_dir, 'corrupt.pickle'), 'wb') as corrupt_file: - corrupt_file.write("This is not a pickle file!") - - old_pickle_dict = {"dlconfig": {"saveas": "dunno", "abc": "def"}, "engineresumedata": "test", - "dlstate": "test", "metainfo": "none"} - self.write_pickle_file(old_pickle_dict, "download.pickle") - - PickleConverter(self.mock_session).convert_download_checkpoints() - - self.assertTrue(os.path.exists(os.path.join(self.session_base_dir, 'download.state'))) - self.assertFalse(os.path.exists(os.path.join(self.session_base_dir, 'corrupt.pickle'))) - - def test_convert_main_config(self): - pickle_dict = {"download_state": {"abc": "stop"}} - self.write_pickle_file(pickle_dict, "user_download_choice.pickle") - - PickleConverter(self.mock_session).convert_main_config() - - self.assertFalse(os.path.exists(os.path.join(self.session_base_dir, "user_download_choice.pickle"))) - self.assertTrue(os.path.exists(os.path.join(self.session_base_dir, TRIBLER_CONFIG_FILENAME))) diff --git a/Tribler/Test/Core/Upgrade/test_torrent_upgrade_63_64.py b/Tribler/Test/Core/Upgrade/test_torrent_upgrade_63_64.py deleted file mode 100644 index 828c2635346..00000000000 --- a/Tribler/Test/Core/Upgrade/test_torrent_upgrade_63_64.py +++ /dev/null @@ -1,122 +0,0 @@ -import os -import shutil -from apsw import Connection -from nose.tools import raises -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Upgrade.torrent_upgrade64 import TorrentMigrator64 -from Tribler.Test.Core.base_test import TriblerCoreTest -from Tribler.Test.common import TORRENT_UBUNTU_FILE - - -class AbstractTorrentUpgrade63to64(TriblerCoreTest): - - FILE_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) - DB_DATA_DIR = os.path.abspath(os.path.join(FILE_DIR, u"../data/upgrade_databases/")) - - def write_data_to_file(self, file_name): - with open(file_name, 'w') as file: - file.write("lorem ipsum") - file.close() - - - # This setup creates a directory with files that should be used for the 6.3 -> 6.4 upgrade - @inlineCallbacks - def setUp(self): - yield super(AbstractTorrentUpgrade63to64, self).setUp() - - self.torrent_collecting_dir = os.path.join(self.session_base_dir, "torrent_collecting") - self.sqlite_path = os.path.join(self.session_base_dir, "sqlite") - os.mkdir(self.torrent_collecting_dir) - os.mkdir(os.path.join(self.torrent_collecting_dir, "test_dir")) - os.mkdir(self.sqlite_path) - - # write and create files - self.write_data_to_file(os.path.join(self.session_base_dir, "upgradingdb.txt")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "test1.mbinmap")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "test2.mhash")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "tmp_test3")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "torrent1.torrent")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "torrent2.torrent")) - os.mkdir(os.path.join(self.torrent_collecting_dir, "swift_reseeds")) - shutil.copyfile(TORRENT_UBUNTU_FILE, os.path.join(self.torrent_collecting_dir, "torrent3.torrent")) - shutil.copyfile(os.path.join(self.DB_DATA_DIR, "torrent_upgrade_64_dispersy.db"), - os.path.join(self.sqlite_path, "dispersy.db")) - - self.torrent_upgrader = TorrentMigrator64(self.torrent_collecting_dir, self.session_base_dir) - - def assert_upgrade_successful(self): - self.assertFalse(os.path.isfile(os.path.join(self.session_base_dir, "upgradingdb.txt"))) - self.assertGreater(self.torrent_upgrader.swift_files_deleted, 0) - self.assertGreater(self.torrent_upgrader.total_swift_file_count, 0) - self.assertGreater(self.torrent_upgrader.total_torrent_file_count, 0) - self.assertGreater(self.torrent_upgrader.processed_file_count, 0) - self.assertGreater(self.torrent_upgrader.torrent_files_dropped, 0) - self.assertGreater(self.torrent_upgrader.total_file_count, 0) - self.assertGreater(self.torrent_upgrader.torrent_files_migrated, 0) - self.assertFalse(os.path.isdir(os.path.join(self.torrent_collecting_dir, "test_dir"))) - - -class TestUpgrade63to64(AbstractTorrentUpgrade63to64): - - def test_upgrade_success(self): - self.torrent_upgrader.start_migrate() - self.torrent_upgrader._update_dispersy() - self.assert_upgrade_successful() - - @raises(OSError) - def test_upgrade_no_valid_basedir(self): - self.torrent_upgrader = TorrentMigrator64(self.torrent_collecting_dir, - os.path.join(self.session_base_dir, "bla")) - self.torrent_upgrader.start_migrate() - - @raises(RuntimeError) - def test_upgrade_no_valid_torrent_collecting_dir(self): - self.torrent_upgrader = TorrentMigrator64(os.path.join(self.torrent_collecting_dir, "bla"), - self.session_base_dir) - self.torrent_upgrader.start_migrate() - - @raises(RuntimeError) - def test_upgrade_temp_torrent_dir_is_file(self): - self.write_data_to_file(os.path.join(self.session_base_dir, ".tmp_migration_v64")) - self.torrent_upgrader = TorrentMigrator64(self.torrent_collecting_dir, self.session_base_dir) - self.torrent_upgrader.start_migrate() - - @raises(RuntimeError) - def test_upgrade_swift_reseeds_dir_no_dir(self): - os.rmdir(os.path.join(self.torrent_collecting_dir, "swift_reseeds")) - self.write_data_to_file(os.path.join(self.torrent_collecting_dir, "swift_reseeds")) - self.torrent_upgrader.start_migrate() - - def test_upgrade_torrent_tcd_file_exists(self): - tcd_path = os.path.join(self.session_base_dir, ".tmp_migration_v64_tcd") - self.write_data_to_file(tcd_path) - self.torrent_upgrader.start_migrate() - self.assertFalse(os.path.exists(tcd_path)) - - def test_upgrade_migration_dir_already_exists(self): - os.mkdir(os.path.join(self.session_base_dir, ".tmp_migration_v64")) - self.torrent_upgrader.start_migrate() - self.assert_upgrade_successful() - - def test_upgrade_empty_torrent_dir(self): - shutil.rmtree(self.torrent_collecting_dir) - os.mkdir(self.torrent_collecting_dir) - self.torrent_upgrader.start_migrate() - self.assertEqual(self.torrent_upgrader.total_torrent_file_count, 0) - self.assertEqual(self.torrent_upgrader.total_swift_file_count, 0) - - def test_upgrade_dispersy_no_database(self): - os.unlink(os.path.join(self.sqlite_path, "dispersy.db")) - self.torrent_upgrader._update_dispersy() - - def test_upgrade_dispersy(self): - self.torrent_upgrader._update_dispersy() - - db_path = os.path.join(self.sqlite_path, u"dispersy.db") - connection = Connection(db_path) - cursor = connection.cursor() - self.assertFalse(list(cursor.execute(u"SELECT * FROM community WHERE classification == 'SearchCommunity'"))) - self.assertFalse(list(cursor.execute(u"SELECT * FROM community WHERE classification == 'MetadataCommunity'"))) - cursor.close() - connection.close() diff --git a/Tribler/Test/Core/Upgrade/test_torrent_upgrade_64_65.py b/Tribler/Test/Core/Upgrade/test_torrent_upgrade_64_65.py deleted file mode 100644 index 5c5f35567de..00000000000 --- a/Tribler/Test/Core/Upgrade/test_torrent_upgrade_64_65.py +++ /dev/null @@ -1,45 +0,0 @@ -import os -import shutil -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.Upgrade.torrent_upgrade65 import TorrentMigrator65 -from Tribler.Core.leveldbstore import LevelDbStore -from Tribler.Test.Core.Upgrade.test_torrent_upgrade_63_64 import AbstractTorrentUpgrade63to64 - - -class AbstractTorrentUpgrade64to65(AbstractTorrentUpgrade63to64): - - @inlineCallbacks - def setUp(self): - yield super(AbstractTorrentUpgrade64to65, self).setUp() - - leveldb_path = os.path.join(self.session_base_dir, "leveldbstore") - os.mkdir(leveldb_path) - self.torrent_store = LevelDbStore(leveldb_path) - self.torrent_upgrader = TorrentMigrator65(self.torrent_collecting_dir, - self.session_base_dir, self.torrent_store) - - def tearDown(self): - self.torrent_store.close() - super(AbstractTorrentUpgrade64to65, self).tearDown() - - def assert_upgrade_successful(self): - self.assertGreater(self.torrent_upgrader.torrent_files_migrated, 0) - self.assertGreater(self.torrent_upgrader.processed_file_count, 0) - self.assertGreater(len(self.torrent_store), 0) - - -class TestTorrentUpgrade63to64(AbstractTorrentUpgrade64to65): - - def test_upgrade_success(self): - self.torrent_upgrader._migrate_torrent_collecting_dir() - self.assert_upgrade_successful() - - def test_torrent_collecting_dir_no_dir(self): - shutil.rmtree(self.torrent_collecting_dir) - self.write_data_to_file(self.torrent_collecting_dir) - self.torrent_upgrader._migrate_torrent_collecting_dir() - - self.assertEqual(self.torrent_upgrader.torrent_files_migrated, 0) - self.assertEqual(self.torrent_upgrader.processed_file_count, 0) - self.assertEqual(len(self.torrent_store), 0) diff --git a/Tribler/Test/Core/Upgrade/test_upgrader.py b/Tribler/Test/Core/Upgrade/test_upgrader.py index 685fc6a55d0..1ecfc286722 100644 --- a/Tribler/Test/Core/Upgrade/test_upgrader.py +++ b/Tribler/Test/Core/Upgrade/test_upgrader.py @@ -1,9 +1,9 @@ -import os +from __future__ import absolute_import + from twisted.internet.defer import Deferred, inlineCallbacks -from Tribler.Core.CacheDB.db_versions import LATEST_DB_VERSION, LOWEST_SUPPORTED_DB_VERSION from Tribler.Core.Upgrade.upgrade import TriblerUpgrader -from Tribler.Core.simpledefs import NTFY_UPGRADER_TICK, NTFY_STARTED +from Tribler.Core.simpledefs import NTFY_STARTED, NTFY_UPGRADER_TICK from Tribler.Test.Core.Upgrade.upgrade_base import AbstractUpgrader from Tribler.Test.tools import trial_timeout @@ -13,38 +13,7 @@ class TestUpgrader(AbstractUpgrader): @inlineCallbacks def setUp(self): yield super(TestUpgrader, self).setUp() - self.copy_and_initialize_upgrade_database('tribler_v17.sdb') - self.upgrader = TriblerUpgrader(self.session, self.sqlitedb) - - def test_stash_database(self): - self.upgrader.stash_database() - old_dir = os.path.dirname(self.sqlitedb.sqlite_db_path) - self.assertTrue(os.path.exists(u'%s_backup_%d' % (old_dir, LATEST_DB_VERSION))) - self.assertIsNotNone(self.sqlitedb._connection) - self.assertTrue(self.upgrader.is_done) - - def test_should_upgrade(self): - self.sqlitedb._version = LATEST_DB_VERSION + 1 - self.assertTrue(self.upgrader.check_should_upgrade_database()[0]) - self.assertFalse(self.upgrader.check_should_upgrade_database()[1]) - - self.sqlitedb._version = LOWEST_SUPPORTED_DB_VERSION - 1 - self.assertTrue(self.upgrader.check_should_upgrade_database()[0]) - self.assertFalse(self.upgrader.check_should_upgrade_database()[1]) - - self.sqlitedb._version = LATEST_DB_VERSION - self.assertFalse(self.upgrader.check_should_upgrade_database()[0]) - self.assertFalse(self.upgrader.check_should_upgrade_database()[1]) - - self.sqlitedb._version = LATEST_DB_VERSION - 1 - self.assertFalse(self.upgrader.check_should_upgrade_database()[0]) - self.assertTrue(self.upgrader.check_should_upgrade_database()[1]) - - def test_upgrade_with_upgrader_enabled(self): - self.upgrader.run() - - self.assertTrue(self.upgrader.is_done) - self.assertFalse(self.upgrader.failed) + self.upgrader = TriblerUpgrader(self.session) def test_run(self): """ diff --git a/Tribler/Test/Core/Upgrade/upgrade_base.py b/Tribler/Test/Core/Upgrade/upgrade_base.py index 094d8e9908d..a0abe3aebcb 100644 --- a/Tribler/Test/Core/Upgrade/upgrade_base.py +++ b/Tribler/Test/Core/Upgrade/upgrade_base.py @@ -4,8 +4,6 @@ from configobj import ConfigObj from twisted.internet.defer import inlineCallbacks -import Tribler -from Tribler.Core.CacheDB.sqlitecachedb import SQLiteCacheDB from Tribler.Core.Config.tribler_config import TriblerConfig, CONFIG_SPEC_PATH from Tribler.Core.Session import Session from Tribler.Test.Core.base_test import TriblerCoreTest @@ -20,43 +18,9 @@ class AbstractUpgrader(TriblerCoreTest): FILE_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) DATABASES_DIR = os.path.abspath(os.path.join(FILE_DIR, u"../data/upgrade_databases/")) - def write_data_to_file(self, file_name): - with open(file_name, 'w') as file: - file.write("lorem ipsum") - file.close() - @inlineCallbacks def setUp(self): yield super(AbstractUpgrader, self).setUp() self.config = TriblerConfig(ConfigObj(configspec=CONFIG_SPEC_PATH)) self.config.set_state_dir(self.getStateDir()) - self.config.set_torrent_collecting_dir(os.path.join(self.session_base_dir, 'torrent_collecting_dir')) self.session = Session(self.config) - self.sqlitedb = None - self.torrent_store = None - - def tearDown(self): - if self.torrent_store: - self.torrent_store.close() - - if self.sqlitedb: - self.sqlitedb.close() - self.sqlitedb = None - - super(AbstractUpgrader, self).tearDown() - - def copy_and_initialize_upgrade_database(self, db_name): - - # create a file to be removed in the thumbnails - os.mkdir(self.session.config.get_torrent_collecting_dir()) - os.mkdir(os.path.join(self.session.config.get_torrent_collecting_dir(), 'dir1')) - self.write_data_to_file(os.path.join(self.session.config.get_torrent_collecting_dir(), 'dir1', 'file1.txt')) - - os.mkdir(os.path.join(self.session_base_dir, 'sqlite')) - shutil.copyfile(os.path.join(self.DATABASES_DIR, db_name), - os.path.join(self.session.config.get_state_dir(), 'sqlite', 'tribler.sdb')) - shutil.copyfile(os.path.join(self.DATABASES_DIR, 'torrent_upgrade_64_dispersy.db'), - os.path.join(self.session.config.get_state_dir(), 'sqlite', 'dispersy.db')) - db_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'tribler.sdb') - self.sqlitedb = SQLiteCacheDB(db_path) - self.session.sqlite_db = self.sqlitedb diff --git a/Tribler/Test/Core/Video/test_vod.py b/Tribler/Test/Core/Video/test_vod.py index 31b1c8221f7..a3be21b2a9b 100644 --- a/Tribler/Test/Core/Video/test_vod.py +++ b/Tribler/Test/Core/Video/test_vod.py @@ -1,15 +1,16 @@ +from __future__ import absolute_import + import os from tempfile import mkstemp -from M2Crypto import Rand -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import inlineCallbacks, Deferred +from twisted.internet.defer import Deferred, inlineCallbacks from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Libtorrent.LibtorrentDownloadImpl import VODFile from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.simpledefs import dlstatus_strings, UPLOAD, DOWNLOAD, DLMODE_VOD +from Tribler.Core.simpledefs import DLMODE_VOD, DOWNLOAD, UPLOAD, dlstatus_strings from Tribler.Test.test_as_server import TestAsServer +from Tribler.Test.tools import trial_timeout class TestVideoOnDemand(TestAsServer): @@ -37,7 +38,7 @@ def setUpPreSession(self): def create_torrent(self): [srchandle, sourcefn] = mkstemp() - self.content = Rand.rand_bytes(self.contentlen) + self.content = '0' * self.contentlen os.write(srchandle, self.content) os.close(srchandle) diff --git a/Tribler/Test/Core/base_test_channel.py b/Tribler/Test/Core/base_test_channel.py deleted file mode 100644 index b14122cdbf5..00000000000 --- a/Tribler/Test/Core/base_test_channel.py +++ /dev/null @@ -1,74 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.simpledefs import NTFY_CHANNELCAST -from Tribler.Core.simpledefs import NTFY_VOTECAST -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.test_as_server import TestAsServer -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.dispersy.dispersy import Dispersy -from Tribler.dispersy.endpoint import ManualEnpoint -from Tribler.dispersy.member import DummyMember - - -class BaseTestChannel(TestAsServer): - - @inlineCallbacks - def setUp(self): - """ - Setup some classes and files that are used by the tests in this module. - """ - yield super(BaseTestChannel, self).setUp() - - self.fake_session = MockObject() - self.fake_session.add_observer = lambda a, b, c: False - - self.fake_session_config = MockObject() - self.fake_session_config.get_state_dir = lambda: self.session_base_dir - self.fake_session.config = self.fake_session_config - - fake_notifier = MockObject() - fake_notifier.add_observer = lambda a, b, c, d: False - fake_notifier.notify = lambda a, b, c, d: False - self.fake_session.notifier = fake_notifier - - self.fake_channel_community = MockObject() - self.fake_channel_community.get_channel_id = lambda: 42 - self.fake_channel_community.cid = 'a' * 20 - self.fake_channel_community.get_channel_name = lambda: "my fancy channel" - - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) - self.votecast_db_handler = self.session.open_dbhandler(NTFY_VOTECAST) - - self.session.get_dispersy = lambda: True - self.session.lm.dispersy = Dispersy(ManualEnpoint(0), self.getStateDir()) - - def setUpPreSession(self): - super(BaseTestChannel, self).setUpPreSession() - self.config.set_megacache_enabled(True) - - def insert_channel_in_db(self, dispersy_cid, peer_id, name, description): - return self.channel_db_handler.on_channel_from_dispersy(dispersy_cid, peer_id, name, description) - - def insert_torrents_into_channel(self, torrent_list): - self.channel_db_handler.on_torrents_from_dispersy(torrent_list) - - def create_fake_allchannel_community(self): - """ - This method creates a fake AllChannel community so we can check whether a request is made in the community - when doing stuff with a channel. - """ - self.session.lm.dispersy._database.open() - fake_member = DummyMember(self.session.lm.dispersy, 1, "a" * 20) - member = self.session.lm.dispersy.get_new_member(u"curve25519") - fake_community = AllChannelCommunity(self.session.lm.dispersy, fake_member, member) - self.session.lm.dispersy._communities = {"allchannel": fake_community} - return fake_community - - @inlineCallbacks - def tearDown(self): - self.session.lm.dispersy.cancel_all_pending_tasks() - # Ugly way to check if database is open in Dispersy - if self.session.lm.dispersy._database._cursor: - yield self.session.lm.dispersy._database.close() - self.session.lm.dispersy = None - yield super(BaseTestChannel, self).tearDown() diff --git a/Tribler/Test/Core/test_launch_many_cores.py b/Tribler/Test/Core/test_launch_many_cores.py index 0d5c75e9f8a..719407ea94a 100644 --- a/Tribler/Test/Core/test_launch_many_cores.py +++ b/Tribler/Test/Core/test_launch_many_cores.py @@ -1,21 +1,19 @@ +from __future__ import absolute_import + import os +from threading import RLock from nose.tools import raises -from Tribler.Test.tools import trial_timeout + from twisted.internet.defer import Deferred -from Tribler.Core import NoDispersyRLock from Tribler.Core.APIImplementation.LaunchManyCore import TriblerLaunchMany from Tribler.Core.Modules.payout_manager import PayoutManager from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.configparser import CallbackConfigParser -from Tribler.Core.simpledefs import DLSTATUS_STOPPED_ON_ERROR, DLSTATUS_SEEDING, DLSTATUS_DOWNLOADING -from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject -from Tribler.Test.common import TESTS_DATA_DIR -from Tribler.Test.test_as_server import TestAsServer -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.community.search.community import SearchCommunity -from Tribler.dispersy.discovery.community import DiscoveryCommunity +from Tribler.Core.simpledefs import DLSTATUS_DOWNLOADING, DLSTATUS_SEEDING, DLSTATUS_STOPPED_ON_ERROR +from Tribler.Test.Core.base_test import MockObject, TriblerCoreTest +from Tribler.Test.tools import trial_timeout class TestLaunchManyCore(TriblerCoreTest): @@ -27,7 +25,7 @@ class TestLaunchManyCore(TriblerCoreTest): def setUp(self): TriblerCoreTest.setUp(self) self.lm = TriblerLaunchMany() - self.lm.session_lock = NoDispersyRLock() + self.lm.session_lock = RLock() self.lm.session = MockObject() self.lm.session.config = MockObject() self.lm.session.config.get_max_upload_rate = lambda: 100 @@ -149,60 +147,3 @@ def mocked_resume_download(filename, setupDelay=3): self.lm.resume_download = mocked_resume_download self.lm.load_checkpoint() self.assertTrue(mocked_resume_download.called) - - def test_resume_download(self): - with open(os.path.join(TESTS_DATA_DIR, "bak_single.torrent"), mode='rb') as torrent_file: - torrent_data = torrent_file.read() - - def mocked_load_download_pstate(_): - raise ValueError() - - def mocked_add(tdef, dscfg, pstate, **_): - self.assertTrue(tdef) - self.assertTrue(dscfg) - self.assertIsNone(pstate) - mocked_add.called = True - mocked_add.called = False - - self.lm.load_download_pstate = mocked_load_download_pstate - self.lm.torrent_store = MockObject() - self.lm.torrent_store.get = lambda _: torrent_data - self.lm.add = mocked_add - self.lm.mypref_db = MockObject() - self.lm.mypref_db.getMyPrefStatsInfohash = lambda _: TESTS_DATA_DIR - self.lm.resume_download('%s.state' % ('a' * 20)) - self.assertTrue(mocked_add.called) - - -class TestLaunchManyCoreFullSession(TestAsServer): - """ - This class contains tests that tests methods in LaunchManyCore when a full session is started. - """ - - def setUpPreSession(self): - TestAsServer.setUpPreSession(self) - - # Enable all communities - config_sections = ['search_community', 'trustchain', 'allchannel_community', 'channel_community', - 'preview_channel_community', 'tunnel_community', 'dispersy', 'ipv8', 'dht'] - - for section in config_sections: - self.config.config[section]['enabled'] = True - - self.config.set_megacache_enabled(True) - self.config.set_tunnel_community_socks5_listen_ports(self.get_socks5_ports()) - self.config.set_ipv8_bootstrap_override("127.0.0.1:12345") - - def get_community(self, community_cls): - for community in self.session.get_dispersy_instance().get_communities(): - if isinstance(community, community_cls): - return community - - def test_load_communities(self): - """ - Testing whether all Dispersy/IPv8 communities can be succesfully loaded - """ - self.assertTrue(self.get_community(DiscoveryCommunity)) - self.assertTrue(self.session.lm.initComplete) - self.assertTrue(self.get_community(SearchCommunity)) - self.assertTrue(self.get_community(AllChannelCommunity)) diff --git a/Tribler/Test/Core/test_leveldb_store.py b/Tribler/Test/Core/test_leveldb_store.py deleted file mode 100644 index 5d966ae7bbe..00000000000 --- a/Tribler/Test/Core/test_leveldb_store.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Tests for the LevelDB. - -Author(s): Elric Milon -""" -import os - -from nose.tools import raises -from shutil import rmtree -from tempfile import mkdtemp -from twisted.internet.task import Clock - -from Tribler.Core.leveldbstore import LevelDbStore, WRITEBACK_PERIOD, get_write_batch_leveldb -from Tribler.Test.Core.base_test import MockObject -from Tribler.Test.test_as_server import BaseTestCase - - -K = "foo" -V = "bar" - - -class ClockedAbstractLevelDBStore(LevelDbStore): - _reactor = Clock() - - -class ClockedLevelDBStore(ClockedAbstractLevelDBStore): - from leveldb import LevelDB - _leveldb = LevelDB - _writebatch = get_write_batch_leveldb - - -class AbstractTestLevelDBStore(BaseTestCase): - - skip = True - _storetype = None - - def __init__(self, *argv, **kwargs): - super(AbstractTestLevelDBStore, self).__init__(*argv, **kwargs) - - self.store_dir = None - self.store = None - - def setUp(self): - self.openStore(mkdtemp(prefix=__name__)) - - def tearDown(self): - self.closeStore() - - def closeStore(self): - self.store.close() - rmtree(self.store_dir) - self.store = None - - def openStore(self, store_dir): - self.store_dir = store_dir - self.store = self._storetype(self.store_dir) - - def test_storeIsPersistent(self): - self.store.put(K, V) - self.assertEqual(self.store.get(K), V) - store_dir = self.store._store_dir - self.store.close() - self.openStore(store_dir) - self.assertEqual(self.store.get(K), V) - - def test_canPutAndDelete(self): - self.store[K] = V - self.assertEqual(self.store[K], V) - del self.store[K] - self.assertEqual(None, self.store.get(K)) - with self.assertRaises(KeyError) as raises: - self.store[K] - - def test_PutGet(self): - self.store._db.Put(K, V) - self.assertEqual(V, self.store._db.Get(K)) - - def test_cacheIsFlushed(self): - self.store[K] = V - self.assertEqual(1, len(self.store._pending_torrents)) - self.store._reactor.advance(WRITEBACK_PERIOD) - self.assertEqual(0, len(self.store._pending_torrents)) - - def test_len(self): - self.assertEqual(0, len(self.store)) - self.store[K] = V - self.assertEqual(1, len(self.store), 1) - # test that even after writing the cached data, the lenght is still the same - self.store.flush() - self.assertEqual(1, len(self.store), 2) - - def test_contains(self): - self.assertFalse(K in self.store) - self.store[K] = V - self.assertTrue(K in self.store) - - @raises(StopIteration) - def test_iter_empty(self): - iteritems = self.store.iteritems() - self.assertTrue(iteritems.next()) - - def test_iter_one_element(self): - self.store[K] = V - iteritems = self.store.iteritems() - self.assertEqual(iteritems.next(), K) - - def test_iter(self): - self.store[K] = V - for key in iter(self.store): - self.assertTrue(key) - - -class TestLevelDBStore(AbstractTestLevelDBStore): - skip = False - _storetype = ClockedLevelDBStore - - def test_invalid_handle(self): - self.store.close() - - open(os.path.join(self.store_dir, 'test.txt'), 'a').close() - - # Make the leveldb files corrupt - for dir_file in os.listdir(self.store_dir): - with open(os.path.join(self.store_dir, dir_file), 'a') as file_handler: - file_handler.write('abcde') - - self.openStore(self.store_dir) - self.assertFalse(os.path.exists(os.path.join(self.store_dir, 'test.txt'))) - - def test_flush(self): - """ Tests if flush() does multiple retries incase of failure. """ - def mock_db_write(store, _): - store.write_retry += 1 - raise Exception("SomeLevelDBError") - - self.store._db = MockObject() - self.store._db.Write = lambda batch: mock_db_write(self.store, batch) - - self.store.write_retry = 0 - - # No store operation yet, no write should be called on flush - self.store.flush() - self.assertEqual(self.store.write_retry, 0) - - # Store something and check if it is in cache - self.store[K] = V - self.assertIsNotNone(self.store._pending_torrents) - # Now flush; Mock DB write through an exception so flush() should be tried 3 times - self.store.flush() - self.assertEqual(self.store.write_retry, 3, "Three retry to flush was expected incase of error") diff --git a/Tribler/Test/Core/test_leveldb_store_plyvel.py b/Tribler/Test/Core/test_leveldb_store_plyvel.py deleted file mode 100644 index 6da8df03fa7..00000000000 --- a/Tribler/Test/Core/test_leveldb_store_plyvel.py +++ /dev/null @@ -1,13 +0,0 @@ -from Tribler.Core.leveldbstore import get_write_batch_plyvel -from Tribler.Test.Core.test_leveldb_store import ClockedAbstractLevelDBStore, AbstractTestLevelDBStore - - -class ClockedPlyvelStore(ClockedAbstractLevelDBStore): - from Tribler.Core.plyveladapter import LevelDB - _leveldb = LevelDB - _writebatch = get_write_batch_plyvel - - -class TestPlyvelStore(AbstractTestLevelDBStore): - skip = False - _storetype = ClockedPlyvelStore diff --git a/Tribler/Test/Core/test_notifier.py b/Tribler/Test/Core/test_notifier.py index e21e67b289f..106d0684652 100644 --- a/Tribler/Test/Core/test_notifier.py +++ b/Tribler/Test/Core/test_notifier.py @@ -1,6 +1,8 @@ +from __future__ import absolute_import + from twisted.internet.defer import inlineCallbacks, Deferred -from Tribler.Core.CacheDB.Notifier import Notifier +from Tribler.Core.Notifier import Notifier from Tribler.Core.simpledefs import NTFY_TORRENTS, NTFY_STARTED, NTFY_FINISHED from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.Test.tools import trial_timeout diff --git a/Tribler/Test/Core/test_permid.py b/Tribler/Test/Core/test_permid.py index 8630e9ce4c2..d697f3db173 100644 --- a/Tribler/Test/Core/test_permid.py +++ b/Tribler/Test/Core/test_permid.py @@ -1,11 +1,12 @@ +from __future__ import absolute_import + import os -from M2Crypto.EC import EC from twisted.internet.defer import inlineCallbacks from Tribler.Core import permid -from Tribler.pyipv8.ipv8.keyvault.private.libnaclkey import LibNaCLSK from Tribler.Test.Core.base_test import TriblerCoreTest +from Tribler.pyipv8.ipv8.keyvault.private.libnaclkey import LibNaCLSK class TriblerCoreTestPermid(TriblerCoreTest): @@ -14,26 +15,10 @@ class TriblerCoreTestPermid(TriblerCoreTest): def setUp(self): yield super(TriblerCoreTestPermid, self).setUp() # All the files are in self.session_base_dir, so they will automatically be cleaned on tearDown() - self.pub_key_path = os.path.join(self.session_base_dir, 'pub_key.pem') - self.key_pair_path = os.path.join(self.session_base_dir, 'pair.pem') self.pub_key_path_trustchain = os.path.join(self.session_base_dir, 'pub_key_multichain.pem') self.key_pair_path_trustchain = os.path.join(self.session_base_dir, 'pair_multichain.pem') - def test_save_load_keypair_pubkey(self): - permid.init() - key = permid.generate_keypair() - - permid.save_keypair(key, self.key_pair_path) - permid.save_pub_key(key, self.pub_key_path) - - self.assertTrue(os.path.isfile(self.pub_key_path)) - self.assertTrue(os.path.isfile(self.key_pair_path)) - - loaded_key = permid.read_keypair(self.key_pair_path) - self.assertIsInstance(loaded_key, EC) - def test_save_load_keypair_pubkey_trustchain(self): - permid.init() key = permid.generate_keypair_trustchain() permid.save_keypair_trustchain(key, self.key_pair_path_trustchain) diff --git a/Tribler/Test/Core/test_session.py b/Tribler/Test/Core/test_session.py index 3a4d8070714..fed565b0c70 100644 --- a/Tribler/Test/Core/test_session.py +++ b/Tribler/Test/Core/test_session.py @@ -1,97 +1,26 @@ -from binascii import hexlify, unhexlify +from __future__ import absolute_import + +from binascii import unhexlify from nose.tools import raises -from Tribler.Test.tools import trial_timeout -from twisted.internet.defer import Deferred, inlineCallbacks -from Tribler.Core.Config.tribler_config import TriblerConfig +from twisted.internet.defer import inlineCallbacks + from Tribler.Core.DownloadConfig import DownloadStartupConfig -from Tribler.Core.Session import Session, SOCKET_BLOCK_ERRORCODE +from Tribler.Core.Session import SOCKET_BLOCK_ERRORCODE from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.exceptions import OperationNotEnabledByConfigurationException, DuplicateTorrentFileError -from Tribler.Core.leveldbstore import LevelDbStore -from Tribler.Core.simpledefs import NTFY_CHANNELCAST, SIGNAL_CHANNEL, SIGNAL_ON_CREATED -from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject +from Tribler.Core.exceptions import OperationNotEnabledByConfigurationException +from Tribler.Test.Core.base_test import MockObject from Tribler.Test.common import TORRENT_UBUNTU_FILE from Tribler.Test.test_as_server import TestAsServer - - -class TestSession(TriblerCoreTest): - - @raises(OperationNotEnabledByConfigurationException) - def test_torrent_store_not_enabled(self): - config = TriblerConfig() - config.set_state_dir(self.getStateDir()) - config.set_torrent_store_enabled(False) - session = Session(config) - session.delete_collected_torrent(None) - - def test_torrent_store_delete(self): - config = TriblerConfig() - config.set_state_dir(self.getStateDir()) - config.set_torrent_store_enabled(True) - session = Session(config) - # Manually set the torrent store as we don't want to start the session. - session.lm.torrent_store = LevelDbStore(session.config.get_torrent_store_dir()) - session.lm.torrent_store[hexlify("fakehash")] = "Something" - self.assertEqual("Something", session.lm.torrent_store[hexlify("fakehash")]) - session.delete_collected_torrent("fakehash") - - raised_key_error = False - # This structure is needed because if we add a @raises above the test, we cannot close the DB - # resulting in a dirty reactor. - try: - self.assertRaises(KeyError,session.lm.torrent_store[hexlify("fakehash")]) - except KeyError: - raised_key_error = True - finally: - session.lm.torrent_store.close() - - self.assertTrue(raised_key_error) - - def test_create_channel(self): - """ - Test the pass through function of Session.create_channel to the ChannelManager. - """ - - class LmMock(object): - class ChannelManager(object): - invoked_name = None - invoked_desc = None - invoked_mode = None - - def create_channel(self, name, description, mode=u"closed"): - self.invoked_name = name - self.invoked_desc = description - self.invoked_mode = mode - - channel_manager = ChannelManager() - - config = TriblerConfig() - config.set_state_dir(self.getStateDir()) - session = Session(config) - session.lm = LmMock() - session.lm.api_manager = None - - session.create_channel("name", "description", "open") - self.assertEqual(session.lm.channel_manager.invoked_name, "name") - self.assertEqual(session.lm.channel_manager.invoked_desc, "description") - self.assertEqual(session.lm.channel_manager.invoked_mode, "open") +from Tribler.Test.tools import trial_timeout class TestSessionAsServer(TestAsServer): - def setUpPreSession(self): - super(TestSessionAsServer, self).setUpPreSession() - self.config.set_megacache_enabled(True) - self.config.set_torrent_collecting_enabled(True) - self.config.set_channel_search_enabled(True) - self.config.set_dispersy_enabled(True) - @inlineCallbacks def setUp(self): yield super(TestSessionAsServer, self).setUp() - self.channel_db_handler = self.session.open_dbhandler(NTFY_CHANNELCAST) self.called = None def mock_endpoints(self): @@ -145,48 +74,6 @@ def on_tribler_exception(_): self.session.unhandled_error_observer({'isError': True, 'log_failure': 'exceptions.RuntimeError: invalid info-hash'}) - @trial_timeout(10) - def test_add_torrent_def_to_channel(self): - """ - Test whether adding a torrent def to a channel works - """ - test_deferred = Deferred() - - torrent_def = TorrentDef.load(TORRENT_UBUNTU_FILE) - - def on_channel_created(subject, change_type, object_id, channel_data): - channel_id = self.channel_db_handler.getMyChannelId() - self.session.add_torrent_def_to_channel(channel_id, torrent_def, {"description": "iso"}, forward=False) - self.assertTrue(self.channel_db_handler.hasTorrent(channel_id, torrent_def.get_infohash())) - test_deferred.callback(None) - - self.session.add_observer(on_channel_created, SIGNAL_CHANNEL, [SIGNAL_ON_CREATED]) - self.session.create_channel("name", "description", "open") - - return test_deferred - - @trial_timeout(10) - def test_add_torrent_def_to_channel_duplicate(self): - """ - Test whether adding a torrent def twice to a channel raises an exception - """ - test_deferred = Deferred() - - torrent_def = TorrentDef.load(TORRENT_UBUNTU_FILE) - - def on_channel_created(subject, change_type, object_id, channel_data): - channel_id = self.channel_db_handler.getMyChannelId() - try: - self.session.add_torrent_def_to_channel(channel_id, torrent_def, forward=False) - self.session.add_torrent_def_to_channel(channel_id, torrent_def, forward=False) - except DuplicateTorrentFileError: - test_deferred.callback(None) - - self.session.add_observer(on_channel_created, SIGNAL_CHANNEL, [SIGNAL_ON_CREATED]) - self.session.create_channel("name", "description", "open") - - return test_deferred - def test_load_checkpoint(self): self.load_checkpoint_called = False @@ -205,69 +92,6 @@ def test_get_libtorrent_process_not_enabled(self): self.session.config.get_libtorrent_enabled = lambda: False self.session.get_libtorrent_process() - @raises(OperationNotEnabledByConfigurationException) - def test_open_dbhandler(self): - """ - Opening the database without the megacache enabled should raise an exception. - """ - self.session.config.get_megacache_enabled = lambda: False - self.session.open_dbhandler("x") - - def test_close_dbhandler(self): - handler = MockObject() - self.called = False - - def verify_close_called(): - self.called = True - handler.close = verify_close_called - Session.close_dbhandler(handler) - self.assertTrue(self.called) - - def test_download_torrentfile(self): - """ - When libtorrent is not enabled, an exception should be thrown when downloading a torrentfile. - """ - self.called = False - - def verify_download_torrentfile_call(*args, **kwargs): - self.called = True - self.session.lm.rtorrent_handler.download_torrent = verify_download_torrentfile_call - - self.session.download_torrentfile() - self.assertTrue(self.called) - - def test_download_torrentfile_from_peer(self): - """ - When libtorrent is not enabled, an exception should be thrown when downloading a torrentfile from a peer. - """ - self.called = False - - def verify_download_torrentfile_call(*args, **kwargs): - self.called = True - self.session.lm.rtorrent_handler.download_torrent = verify_download_torrentfile_call - - self.session.download_torrentfile_from_peer("a") - self.assertTrue(self.called) - - def test_download_torrentmessage_from_peer(self): - """ - When libtorrent is not enabled, an exception should be thrown when downloading a torrentfile from a peer. - """ - self.called = False - - def verify_download_torrentmessage_call(*args, **kwargs): - self.called = True - self.session.lm.rtorrent_handler.download_torrentmessage = verify_download_torrentmessage_call - - self.session.download_torrentmessage_from_peer("a", "b", "c") - self.assertTrue(self.called) - - def test_get_permid(self): - """ - Retrieving the string encoded permid should be successful. - """ - self.assertIsInstance(self.session.get_permid(), str) - def test_remove_download_by_id_empty(self): """ Remove downloads method when empty. @@ -295,14 +119,6 @@ def verify_remove_download_called(*args, **kwargs): self.session.remove_download_by_id(infohash) self.assertTrue(self.called) - @raises(OperationNotEnabledByConfigurationException) - def test_get_dispersy_instance(self): - """ - Test whether the get dispersy instance throws an exception if dispersy is not enabled. - """ - self.session.config.get_dispersy_enabled = lambda: False - self.session.get_dispersy_instance() - @raises(OperationNotEnabledByConfigurationException) def test_get_ipv8_instance(self): """ @@ -311,54 +127,6 @@ def test_get_ipv8_instance(self): self.session.config.set_ipv8_enabled(False) self.session.get_ipv8_instance() - @raises(OperationNotEnabledByConfigurationException) - def test_has_collected_torrent(self): - """ - Test whether the has_collected_torrent throws an exception if dispersy is not enabled. - """ - self.session.config.get_torrent_store_enabled = lambda: False - self.session.has_collected_torrent(None) - - @raises(OperationNotEnabledByConfigurationException) - def test_get_collected_torrent(self): - """ - Test whether the get_collected_torrent throws an exception if dispersy is not enabled. - """ - self.session.config.get_torrent_store_enabled = lambda: False - self.session.get_collected_torrent(None) - - @raises(OperationNotEnabledByConfigurationException) - def test_save_collected_torrent(self): - """ - Test whether the save_collected_torrent throws an exception if dispersy is not enabled. - """ - self.session.config.get_torrent_store_enabled = lambda: False - self.session.save_collected_torrent(None, None) - - @raises(OperationNotEnabledByConfigurationException) - def test_delete_collected_torrent(self): - """ - Test whether the delete_collected_torrent throws an exception if dispersy is not enabled. - """ - self.session.config.get_torrent_store_enabled = lambda: False - self.session.delete_collected_torrent(None) - - @raises(OperationNotEnabledByConfigurationException) - def test_search_remote_channels(self): - """ - Test whether the search_remote_channels throws an exception if dispersy is not enabled. - """ - self.session.config.get_channel_search_enabled = lambda: False - self.session.search_remote_channels(None) - - @raises(OperationNotEnabledByConfigurationException) - def test_get_thumbnail_data(self): - """ - Test whether the get_thumbnail_data throws an exception if dispersy is not enabled. - """ - self.session.lm.metadata_store = None - self.session.get_thumbnail_data(None) - class TestSessionWithLibTorrent(TestSessionAsServer): diff --git a/Tribler/Test/Core/test_sqlitecachedb.py b/Tribler/Test/Core/test_sqlitecachedb.py deleted file mode 100644 index 8beb3500916..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedb.py +++ /dev/null @@ -1,231 +0,0 @@ -import os -import shutil -import sys -from unittest import skipIf - -from apsw import SQLError, CantOpenError -from nose.tools import raises -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.sqlitecachedb import SQLiteCacheDB, DB_SCRIPT_ABSOLUTE_PATH, CorruptedDatabaseError -from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject - - -class TestSqliteCacheDB(TriblerCoreTest): - - FILE_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) - SQLITE_SCRIPTS_DIR = os.path.abspath(os.path.join(FILE_DIR, u"data/sqlite_scripts/")) - - @inlineCallbacks - def setUp(self): - yield super(TestSqliteCacheDB, self).setUp() - - db_path = u":memory:" - - self.sqlite_test = SQLiteCacheDB(db_path) - self.sqlite_test.set_show_sql(True) - - def tearDown(self): - self.sqlite_test.close() - self.sqlite_test = None - super(TestSqliteCacheDB, self).tearDown() - - def test_create_db(self): - sql = u"CREATE TABLE person(lastname, firstname);" - self.sqlite_test.execute(sql) - - self.assertIsInstance(self.sqlite_test.version, int) - - @raises(OSError) - def test_no_file_db_error(self): - file_dir = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) - sqlite_test_2 = SQLiteCacheDB(file_dir) - - def test_open_db_new_file(self): - db_path = os.path.join(self.session_base_dir, "test_db.db") - sqlite_test_2 = SQLiteCacheDB(db_path) - self.assertTrue(os.path.isfile(db_path)) - - @raises(OSError) - def test_open_db_script_file_invalid_location(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), u'myfakelocation') - - @raises(OSError) - def test_open_db_script_file_directory(self): - file_dir = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), file_dir) - - def test_open_db_script_file(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), DB_SCRIPT_ABSOLUTE_PATH) - - sqlite_test_2.write_version(4) - self.assertEqual(sqlite_test_2.version, 4) - - @raises(SQLError) - def test_failed_commit(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), DB_SCRIPT_ABSOLUTE_PATH) - sqlite_test_2.initial_begin() - sqlite_test_2.write_version(4) - - @skipIf(sys.platform == "win32", "chmod does not work on Windows") - @raises(IOError) - def test_no_permission_on_script(self): - db_path = os.path.join(self.session_base_dir, "test_db.db") - new_script_path = os.path.join(self.session_base_dir, "script.sql") - shutil.copyfile(DB_SCRIPT_ABSOLUTE_PATH, new_script_path) - os.chmod(new_script_path, 0) - sqlite_test_2 = SQLiteCacheDB(db_path, new_script_path) - - @raises(CorruptedDatabaseError) - def test_no_version_info_in_database(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), - os.path.join(self.SQLITE_SCRIPTS_DIR, "script1.sql")) - - @raises(CorruptedDatabaseError) - def test_integrity_check_failed(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), - os.path.join(self.SQLITE_SCRIPTS_DIR, "script1.sql")) - - def execute(sql): - if sql == u"PRAGMA quick_check": - db_response = MockObject() - db_response.next = lambda: ("Error: database disk image is malformed", ) - return db_response - - sqlite_test_2.execute = execute - - def test_integrity_check_triggered(self): - """ Tests if integrity check is triggered if temporary rollback files are present.""" - def do_integrity_check(_): - do_integrity_check.called = True - - db_path = os.path.join(self.session_base_dir, "test_db.db") - sqlite_test = SQLiteCacheDB(db_path) - sqlite_test.do_quick_integrity_check = do_integrity_check - do_integrity_check.called = False - self.assertFalse(do_integrity_check.called) - - db_path2 = os.path.join(self.session_base_dir, "test_db2.db") - wal_file = open(os.path.join(self.session_base_dir, "test_db2.db-shm"), 'w') - wal_file.close() - - do_integrity_check.called = False - SQLiteCacheDB.do_quick_integrity_check = do_integrity_check - sqlite_test_2 = SQLiteCacheDB(db_path2) - self.assertTrue(do_integrity_check.called) - - def test_clean_db(self): - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db"), DB_SCRIPT_ABSOLUTE_PATH) - sqlite_test_2.clean_db(vacuum=True, exiting=False) - sqlite_test_2.close() - - @skipIf(sys.platform == "win32", "chmod does not work on Windows") - @raises(CantOpenError) - def test_open_db_connection_no_permission(self): - os.chmod(os.path.join(self.session_base_dir), 0) - sqlite_test_2 = SQLiteCacheDB(os.path.join(self.session_base_dir, "test_db.db")) - - def test_insert(self): - self.test_create_db() - - self.sqlite_test.insert('person', lastname='a', firstname='b') - self.assertEqual(self.sqlite_test.size('person'), 1) - - def test_fetchone(self): - self.test_insert() - one = self.sqlite_test.fetchone(u"SELECT * FROM person") - self.assertEqual(one, ('a', 'b')) - - one = self.sqlite_test.fetchone(u"SELECT lastname FROM person WHERE firstname == 'b'") - self.assertEqual(one, 'a') - - one = self.sqlite_test.fetchone(u"SELECT lastname FROM person WHERE firstname == 'c'") - self.assertIsNone(one) - - def test_insertmany(self): - self.test_create_db() - - values = [] - for i in range(100): - value = (str(i), str(i ** 2)) - values.append(value) - self.sqlite_test.insertMany('person', values) - self.assertEqual(self.sqlite_test.size('person'), 100) - - def test_fetchall(self): - self.test_insertmany() - - all = self.sqlite_test.fetchall('select * from person') - self.assertEqual(len(all), 100) - - all = self.sqlite_test.fetchall("select * from person where lastname=='101'") - self.assertEqual(all, []) - - def test_insertorder(self): - self.test_insertmany() - - self.sqlite_test.insert('person', lastname='1', firstname='abc') - one = self.sqlite_test.fetchone("select firstname from person where lastname == '1'") - self.assertTrue(one == '1' or one == 'abc') - - all = self.sqlite_test.fetchall("select firstname from person where lastname == '1'") - self.assertEqual(len(all), 2) - - def test_update(self): - self.test_insertmany() - - self.sqlite_test.update('person', "lastname == '2'", firstname='56') - one = self.sqlite_test.fetchone("select firstname from person where lastname == '2'") - self.assertEqual(one, '56') - - self.sqlite_test.update('person', "lastname == '3'", firstname=65) - one = self.sqlite_test.fetchone("select firstname from person where lastname == '3'") - self.assertEqual(one, 65) - - self.sqlite_test.update('person', "lastname == '4'", firstname=654, lastname=44) - one = self.sqlite_test.fetchone("select firstname from person where lastname == 44") - self.assertEqual(one, 654) - - def test_delete_single_element(self): - """ - This test tests whether deleting using a single element as value works. - """ - self.test_insert() - self.sqlite_test.insert('person', lastname='x', firstname='z') - one = self.sqlite_test.fetchone(u"SELECT * FROM person") - self.assertEqual(one, ('a', 'b')) - self.sqlite_test.delete("person", lastname="a") - one = self.sqlite_test.fetchone(u"SELECT * FROM person") - self.assertEqual(one, ('x', 'z')) - - def test_delete_tuple(self): - """ - This test tests whether deleting using a tuple as value works. - """ - self.test_insert() - self.sqlite_test.insert('person', lastname='x', firstname='z') - one = self.sqlite_test.fetchone(u"SELECT * FROM person") - self.assertEqual(one, ('a', 'b')) - self.sqlite_test.delete("person", lastname=("LIKE", "a")) - one = self.sqlite_test.fetchone(u"SELECT * FROM person") - self.assertEqual(one, ('x', 'z')) - - def test_commit_now_error_non_exit(self): - """ - Test if commit_now raises an error when we are not exiting. - """ - self.test_insert() - self.sqlite_test.insert('person', lastname='x', firstname='z') - self.sqlite_test.execute(u"COMMIT;") - self.assertRaises(SQLError, self.sqlite_test.commit_now) - - def test_commit_now_error_on_exit(self): - """ - Test if commit_now does not raise an error when we are exiting. - - See also test_commit_now_error_non_exit. - """ - self.test_insert() - self.sqlite_test.insert('person', lastname='x', firstname='z') - self.sqlite_test.execute(u"COMMIT;") - self.assertIsNone(self.sqlite_test.commit_now(exiting=True)) diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler.py b/Tribler/Test/Core/test_sqlitecachedbhandler.py deleted file mode 100644 index b357c1bf6ce..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import tarfile - -from configobj import ConfigObj -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import (BasicDBHandler, LimitedOrderedDict) -from Tribler.Core.CacheDB.sqlitecachedb import SQLiteCacheDB -from Tribler.Core.Config.tribler_config import TriblerConfig, CONFIG_SPEC_PATH -from Tribler.Core.Session import Session -from Tribler.Test.Core.base_test import TriblerCoreTest -from Tribler.Test.common import TESTS_DATA_DIR - -BUSYTIMEOUT = 5000 - - -class TestLimitedOrderedDict(TriblerCoreTest): - - def test_limited_ordered_dict(self): - od = LimitedOrderedDict(3) - od['foo'] = 'bar' - od['bar'] = 'foo' - od['foobar'] = 'foobar' - self.assertEqual(len(od), 3) - od['another'] = 'another' - self.assertEqual(len(od), 3) - - -class AbstractDB(TriblerCoreTest): - - def setUpPreSession(self): - self.config = TriblerConfig(ConfigObj(configspec=CONFIG_SPEC_PATH)) - self.config.set_state_dir(self.getStateDir()) - self.config.set_torrent_checking_enabled(False) - self.config.set_megacache_enabled(False) - self.config.set_dispersy_enabled(False) - self.config.set_mainline_dht_enabled(False) - self.config.set_torrent_collecting_enabled(False) - self.config.set_libtorrent_enabled(False) - self.config.set_video_server_enabled(False) - self.config.set_torrent_store_enabled(False) - - @inlineCallbacks - def setUp(self): - yield super(AbstractDB, self).setUp() - - self.setUpPreSession() - self.session = Session(self.config) - - tar = tarfile.open(os.path.join(TESTS_DATA_DIR, 'bak_new_tribler.sdb.tar.gz'), 'r|gz') - tar.extractall(self.session_base_dir) - - db_path = os.path.join(self.session_base_dir, 'bak_new_tribler.sdb') - - self.sqlitedb = SQLiteCacheDB(db_path, busytimeout=BUSYTIMEOUT) - self.session.sqlite_db = self.sqlitedb - - @inlineCallbacks - def tearDown(self): - self.sqlitedb.close() - self.sqlitedb = None - self.session = None - - yield super(AbstractDB, self).tearDown() - - -class TestSqliteBasicDBHandler(AbstractDB): - - @inlineCallbacks - def setUp(self): - yield super(TestSqliteBasicDBHandler, self).setUp() - self.db = BasicDBHandler(self.session, u"Peer") - - def test_size(self): - size = self.db.size() # there are 3995 peers in the table, however the upgrade scripts remove 8 superpeers - assert size == 3987, size diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler_channels.py b/Tribler/Test/Core/test_sqlitecachedbhandler_channels.py deleted file mode 100644 index a95ceb11c18..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler_channels.py +++ /dev/null @@ -1,126 +0,0 @@ -from binascii import unhexlify - -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import ChannelCastDBHandler, TorrentDBHandler, VoteCastDBHandler -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Test.Core.test_sqlitecachedbhandler import AbstractDB - - -class TestChannelDBHandler(AbstractDB): - - @inlineCallbacks - def setUp(self): - yield super(TestChannelDBHandler, self).setUp() - - self.cdb = ChannelCastDBHandler(self.session) - self.tdb = TorrentDBHandler(self.session) - self.vdb = VoteCastDBHandler(self.session) - self.cdb.votecast_db = self.vdb - self.cdb.torrent_db = self.tdb - - def test_get_metadata_torrents(self): - self.assertEqual(len(self.cdb.get_metadata_torrents()), 2) - self.assertEqual(len(self.cdb.get_metadata_torrents(is_collected=False)), 1) - - def test_get_torrent_metadata(self): - result = self.cdb.get_torrent_metadata(1) - self.assertEqual(result, {"thumb_hash": unhexlify("1234")}) - self.assertIsNone(self.cdb.get_torrent_metadata(200)) - - def test_get_dispersy_cid_from_channel_id(self): - self.assertEqual(self.cdb.getDispersyCIDFromChannelId(1), "1") - self.assertEqual(self.cdb.getDispersyCIDFromChannelId(3), "3") - - def test_get_channel_id_from_dispersy_cid(self): - self.assertEqual(self.cdb.getChannelIdFromDispersyCID(1), 1) - self.assertEqual(self.cdb.getChannelIdFromDispersyCID(3), 3) - - def test_get_count_max_from_channel_id(self): - self.assertEqual(self.cdb.getCountMaxFromChannelId(1), (2, 1457809687)) - self.assertEqual(self.cdb.getCountMaxFromChannelId(2), (1, 1457809861)) - - def test_search_channel(self): - self.assertEqual(len(self.cdb.searchChannels("another")), 1) - self.assertEqual(len(self.cdb.searchChannels("fancy")), 2) - - def test_get_channel(self): - channel = self.cdb.getChannel(1) - self.assertEqual(channel, (1, '1', u'Test Channel 1', u'Test', 3, 7, 5, 2, 1457795713, False)) - self.assertIsNone(self.cdb.getChannel(1234)) - - def test_get_channels(self): - channels = self.cdb.getChannels([1, 2, 3]) - self.assertEqual(len(channels), 3) - - def test_get_channels_by_cid(self): - self.assertEqual(len(self.cdb.getChannelsByCID(["3"])), 0) - - def test_get_all_channels(self): - self.assertEqual(len(self.cdb.getAllChannels()), 3) - - def test_get_new_channels(self): - self.assertEqual(len(self.cdb.getNewChannels()), 1) - - def test_get_latest_updated(self): - res = self.cdb.getLatestUpdated() - self.assertEqual(res[0][0], 6) - self.assertEqual(res[1][0], 7) - self.assertEqual(res[2][0], 5) - - def test_get_most_popular_channels(self): - res = self.cdb.getMostPopularChannels() - self.assertEqual(res[0][0], 6) - self.assertEqual(res[1][0], 7) - self.assertEqual(res[2][0], 8) - - def test_get_my_subscribed_channels(self): - res = self.cdb.getMySubscribedChannels(include_dispersy=True) - self.assertEqual(len(res), 1) - res = self.cdb.getMySubscribedChannels() - self.assertEqual(len(res), 0) - - def test_get_channels_no_votecast(self): - self.cdb.votecast_db = None - self.assertFalse(self.cdb._getChannels("SELECT id FROM channels")) - - def test_get_channel_empty_name(self): - update_channel = "INSERT INTO _Channels (dispersy_cid, peer_id, name, description) VALUES(?, ?, ?, ?)" - self.cdb._db.execute_write(update_channel, ('', '', '', 'unique_desc_123')) - - sql = "Select id, name, description, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " + \ - "FROM Channels WHERE description = 'unique_desc_123'" - self.assertEqual(self.cdb._getChannels(sql)[0][2], 'Unnamed channel') - - def test_get_my_channel_id(self): - self.cdb._channel_id = 42 - self.assertEqual(self.cdb.getMyChannelId(), 42) - self.cdb._channel_id = None - self.assertEqual(self.cdb.getMyChannelId(), 1) - - def test_get_torrent_markings(self): - res = self.cdb.getTorrentMarkings(3) - self.assertEqual(res, [[u'test', 2, True], [u'another', 1, True]]) - res = self.cdb.getTorrentMarkings(1) - self.assertEqual(res, [[u'test', 1, True]]) - - def test_on_remove_playlist_torrent(self): - self.assertEqual(len(self.cdb.getTorrentsFromPlaylist(1, ['Torrent.torrent_id'])), 1) - self.cdb.on_remove_playlist_torrent(1, 1, str2bin('AA8cTG7ZuPsyblbRE7CyxsrKUCg='), False) - self.assertEqual(len(self.cdb.getTorrentsFromPlaylist(1, ['Torrent.torrent_id'])), 0) - - def test_on_remove_torrent_from_dispersy(self): - self.assertEqual(self.cdb.getTorrentFromChannelTorrentId(1, ['ChannelTorrents.dispersy_id']), 3) - self.cdb.on_remove_torrent_from_dispersy(1, 3, False) - self.assertIsNone(self.cdb.getTorrentFromChannelTorrentId(1, ['ChannelTorrents.dispersy_id'])) - - def test_search_local_channels(self): - """ - Testing whether the right results are returned when searching in the local database for channels - """ - results = self.cdb.search_in_local_channels_db("fancy", chan_size_limit=None) - self.assertEqual(len(results), 2) - self.assertNotEqual(results[0][-1], 0.0) # Relevance score of result should not be zero - - results = self.cdb.search_in_local_channels_db("fdajlkerhui") - self.assertEqual(len(results), 0) diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler_peers.py b/Tribler/Test/Core/test_sqlitecachedbhandler_peers.py deleted file mode 100644 index e40ecbb628d..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler_peers.py +++ /dev/null @@ -1,96 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import PeerDBHandler -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Test.Core.test_sqlitecachedbhandler import AbstractDB - - -FAKE_PERMID_X = 'fake_permid_x' + '0R0\x10\x00\x07*\x86H\xce=\x02\x01\x06\x05+\x81\x04\x00\x1a\x03>\x00\x04' - - -class TestSqlitePeerDBHandler(AbstractDB): - - @inlineCallbacks - def setUp(self): - yield super(TestSqlitePeerDBHandler, self).setUp() - - self.p1 = str2bin( - 'MFIwEAYHKoZIzj0CAQYFK4EEABoDPgAEAAA6SYI4NHxwQ8P7P8QXgWAP+v8SaMVzF5+fSUHdAMrs6NvL5Epe1nCNSdlBHIjNjEiC5iiwSFZhRLsr') - self.p2 = str2bin( - 'MFIwEAYHKoZIzj0CAQYFK4EEABoDPgAEAABo69alKy95H7RHzvDCsolAurKyrVvtDdT9/DzNAGvky6YejcK4GWQXBkIoQGQgxVEgIn8dwaR9B+3U') - - self.pdb = PeerDBHandler(self.session) - - self.assertFalse(self.pdb.hasPeer(FAKE_PERMID_X)) - - @inlineCallbacks - def tearDown(self): - self.pdb.close() - self.pdb = None - yield super(TestSqlitePeerDBHandler, self).tearDown() - - def test_getList(self): - peer1 = self.pdb.getPeer(self.p1) - peer2 = self.pdb.getPeer(self.p2) - self.assertIsInstance(peer1, dict) - self.assertIsInstance(peer2, dict) - self.assertEqual(peer1[u'peer_id'], 1) - self.assertEqual(peer2[u'peer_id'], 2) - - def test_addPeer(self): - peer_x = {'permid': FAKE_PERMID_X, 'name': 'fake peer x'} - oldsize = self.pdb.size() - self.pdb.addPeer(FAKE_PERMID_X, peer_x) - self.assertEqual(self.pdb.size(), oldsize + 1) - - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertEqual(p['name'], 'fake peer x') - - self.assertEqual(self.pdb.getPeer(FAKE_PERMID_X, 'name'), 'fake peer x') - - self.pdb.deletePeer(FAKE_PERMID_X) - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertIsNone(p) - self.assertEqual(self.pdb.size(), oldsize) - - self.pdb.addPeer(FAKE_PERMID_X, peer_x) - self.pdb.addPeer(FAKE_PERMID_X, {'permid': FAKE_PERMID_X, 'name': 'faka peer x'}) - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertEqual(p['name'], 'faka peer x') - - def test_aa_hasPeer(self): - self.assertTrue(self.pdb.hasPeer(self.p1)) - self.assertTrue(self.pdb.hasPeer(self.p1, check_db=True)) - self.assertTrue(self.pdb.hasPeer(self.p2)) - self.assertFalse(self.pdb.hasPeer(FAKE_PERMID_X)) - - def test_deletePeer(self): - peer_x = {'permid': FAKE_PERMID_X, 'name': 'fake peer x'} - oldsize = self.pdb.size() - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertIsNone(p) - - self.pdb.addPeer(FAKE_PERMID_X, peer_x) - self.assertEqual(self.pdb.size(), oldsize + 1) - self.assertTrue(self.pdb.hasPeer(FAKE_PERMID_X)) - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertIsNotNone(p) - - self.pdb.deletePeer(FAKE_PERMID_X) - self.assertFalse(self.pdb.hasPeer(FAKE_PERMID_X)) - self.assertEqual(self.pdb.size(), oldsize) - - p = self.pdb.getPeer(FAKE_PERMID_X) - self.assertIsNone(p) - - self.assertFalse(self.pdb.deletePeer(FAKE_PERMID_X)) - - def test_add_or_get_peer(self): - self.assertIsInstance(self.pdb.addOrGetPeerID(FAKE_PERMID_X), int) - self.assertIsInstance(self.pdb.addOrGetPeerID(FAKE_PERMID_X), int) - - def test_get_peer_by_id(self): - self.assertEqual(self.pdb.getPeerById(1, ['name']), 'Peer 1') - p = self.pdb.getPeerById(1) - self.assertEqual(p['name'], 'Peer 1') - self.assertFalse(self.pdb.getPeerById(1234567)) diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler_preferences.py b/Tribler/Test/Core/test_sqlitecachedbhandler_preferences.py deleted file mode 100644 index 6482401351f..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler_preferences.py +++ /dev/null @@ -1,101 +0,0 @@ -from __future__ import absolute_import - -from six import string_types -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler, MyPreferenceDBHandler -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Test.Core.test_sqlitecachedbhandler import AbstractDB - - -class TestMyPreferenceDBHandler(AbstractDB): - - @inlineCallbacks - def setUp(self): - yield super(TestMyPreferenceDBHandler, self).setUp() - - self.tdb = TorrentDBHandler(self.session) - self.mdb = MyPreferenceDBHandler(self.session) - self.mdb._torrent_db = self.tdb - - def tearDown(self): - self.mdb.close() - self.mdb = None - self.tdb.close() - self.tdb = None - - super(TestMyPreferenceDBHandler, self).tearDown() - - def test_getPrefList(self): - pl = self.mdb.getMyPrefListInfohash() - self.assertEqual(len(pl), 12) - - def test_addMyPreference_deletePreference(self): - p = self.mdb.getOne(('torrent_id', 'destination_path', 'creation_time'), torrent_id=126) - torrent_id = p[0] - infohash = self.tdb.getInfohash(torrent_id) - destpath = p[1] - creation_time = p[2] - self.mdb.deletePreference(torrent_id) - pl = self.mdb.getMyPrefListInfohash() - self.assertEqual(len(pl), 12) - self.assertIn(infohash, pl) - - data = {'destination_path': destpath} - self.mdb.addMyPreference(torrent_id, data) - p2 = self.mdb.getOne(('torrent_id', 'destination_path', 'creation_time'), torrent_id=126) - self.assertTrue(p2[0] == p[0]) - self.assertTrue(p2[1] == p[1]) - - self.mdb.deletePreference(torrent_id) - pl = self.mdb.getMyPrefListInfohash(returnDeleted=False) - self.assertEqual(len(pl), 11) - self.assertNotIn(infohash, pl) - - data = {'destination_path': destpath, 'creation_time': creation_time} - self.mdb.addMyPreference(torrent_id, data) - p3 = self.mdb.getOne(('torrent_id', 'destination_path', 'creation_time'), torrent_id=126) - self.assertEqual(p3, p) - - def test_getMyPrefListInfohash(self): - preflist = self.mdb.getMyPrefListInfohash() - for p in preflist: - self.assertTrue(not p or len(p) == 20) - self.assertEqual(len(preflist), 12) - - def test_get_my_pref_stats(self): - res = self.mdb.getMyPrefStats() - self.assertEqual(len(res), 12) - for k in res: - data = res[k] - self.assertIsInstance(data, string_types, "data is not destination_path: %s" % type(data)) - - res = self.mdb.getMyPrefStats(torrent_id=126) - self.assertEqual(len(res), 1) - - def test_my_pref_stats_infohash(self): - infohash = str2bin('AB8cTG7ZuPsyblbRE7CyxsrKUCg=') - self.assertIsNone(self.mdb.getMyPrefStatsInfohash(infohash)) - infohash = str2bin('ByJho7yj9mWY1ORWgCZykLbU1Xc=') - self.assertTrue(self.mdb.getMyPrefStatsInfohash(infohash)) - - def test_get_my_pref_list_infohash_limit(self): - self.assertEqual(len(self.mdb.getMyPrefListInfohash(limit=10)), 10) - - def test_add_my_preference(self): - self.assertTrue(self.mdb.addMyPreference(127, {'destination_path': 'C:/mytorrent'})) - self.assertTrue(self.mdb.addMyPreference(12345678, {'destination_path': 'C:/mytorrent'})) - self.assertFalse(self.mdb.addMyPreference(12345678, {'destination_path': 'C:/mytorrent'})) - - def test_delete_my_preference(self): - self.mdb.deletePreference(126) - res = self.mdb.getMyPrefStats(126) - self.assertFalse(res[126]) - self.mdb.deletePreference(12348934) - - def test_update_dest_dir(self): - self.mdb.updateDestDir(126, 'C:/mydest') - res = self.mdb.getMyPrefStats(126) - self.assertEqual(res[126], 'C:/mydest') - self.mdb.updateDestDir(126, {}) - self.assertEqual(res[126], 'C:/mydest') diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler_torrents.py b/Tribler/Test/Core/test_sqlitecachedbhandler_torrents.py deleted file mode 100644 index b698efaca09..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler_torrents.py +++ /dev/null @@ -1,307 +0,0 @@ -import os -import struct -from binascii import unhexlify -from shutil import copy as copyfile - -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import TorrentDBHandler, MyPreferenceDBHandler, ChannelCastDBHandler -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Core.Category.Category import Category -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.leveldbstore import LevelDbStore -from Tribler.Test.Core.test_sqlitecachedbhandler import AbstractDB -from Tribler.Test.common import TESTS_DATA_DIR - -S_TORRENT_PATH_BACKUP = os.path.join(TESTS_DATA_DIR, 'bak_single.torrent') -M_TORRENT_PATH_BACKUP = os.path.join(TESTS_DATA_DIR, 'bak_multiple.torrent') - - -class TestTorrentFullSessionDBHandler(AbstractDB): - - def setUpPreSession(self): - super(TestTorrentFullSessionDBHandler, self).setUpPreSession() - self.config.set_megacache_enabled(True) - - @inlineCallbacks - def setUp(self): - yield super(TestTorrentFullSessionDBHandler, self).setUp() - self.tdb = TorrentDBHandler(self.session) - - def test_initialize(self): - self.tdb.initialize() - self.assertIsNone(self.tdb.mypref_db) - self.assertIsNone(self.tdb.votecast_db) - self.assertIsNone(self.tdb.channelcast_db) - - -class TestTorrentDBHandler(AbstractDB): - - def addTorrent(self): - old_size = self.tdb.size() - old_tracker_size = self.tdb._db.size('TrackerInfo') - - s_infohash = unhexlify('44865489ac16e2f34ea0cd3043cfd970cc24ec09') - m_infohash = unhexlify('ed81da94d21ad1b305133f2726cdaec5a57fed98') - - single_torrent_file_path = os.path.join(self.getStateDir(), 'single.torrent') - multiple_torrent_file_path = os.path.join(self.getStateDir(), 'multiple.torrent') - - copyfile(S_TORRENT_PATH_BACKUP, single_torrent_file_path) - copyfile(M_TORRENT_PATH_BACKUP, multiple_torrent_file_path) - - single_tdef = TorrentDef.load(single_torrent_file_path) - self.assertEqual(s_infohash, single_tdef.get_infohash()) - multiple_tdef = TorrentDef.load(multiple_torrent_file_path) - self.assertEqual(m_infohash, multiple_tdef.get_infohash()) - - self.tdb.addExternalTorrent(single_tdef) - self.tdb.addExternalTorrent(multiple_tdef) - - single_torrent_id = self.tdb.getTorrentID(s_infohash) - multiple_torrent_id = self.tdb.getTorrentID(m_infohash) - - self.assertEqual(self.tdb.getInfohash(single_torrent_id), s_infohash) - - single_name = 'Tribler_4.1.7_src.zip' - multiple_name = 'Tribler_4.1.7_src' - - self.assertEqual(self.tdb.size(), old_size + 2) - new_tracker_table_size = self.tdb._db.size('TrackerInfo') - self.assertLess(old_tracker_size, new_tracker_table_size) - - sname = self.tdb.getOne('name', torrent_id=single_torrent_id) - self.assertEqual(sname, single_name) - mname = self.tdb.getOne('name', torrent_id=multiple_torrent_id) - self.assertEqual(mname, multiple_name) - - s_size = self.tdb.getOne('length', torrent_id=single_torrent_id) - self.assertEqual(s_size, 1583233) - m_size = self.tdb.getOne('length', torrent_id=multiple_torrent_id) - self.assertEqual(m_size, 5358560) - - cat = self.tdb.getOne('category', torrent_id=multiple_torrent_id) - self.assertEqual(cat, u'xxx') - - s_status = self.tdb.getOne('status', torrent_id=single_torrent_id) - self.assertEqual(s_status, u'unknown') - - m_comment = self.tdb.getOne('comment', torrent_id=multiple_torrent_id) - comments = 'www.tribler.org' - self.assertGreater(m_comment.find(comments), -1) - comments = 'something not inside' - self.assertEqual(m_comment.find(comments), -1) - - m_trackers = self.tdb.getTrackerListByInfohash(m_infohash) - self.assertEqual(len(m_trackers), 8) - self.assertIn('http://tpb.tracker.thepiratebay.org/announce', m_trackers) - - s_torrent = self.tdb.getTorrent(s_infohash) - m_torrent = self.tdb.getTorrent(m_infohash) - self.assertEqual(s_torrent['name'], 'Tribler_4.1.7_src.zip') - self.assertEqual(m_torrent['name'], 'Tribler_4.1.7_src') - self.assertEqual(m_torrent['last_tracker_check'], 0) - - def updateTorrent(self): - m_infohash = unhexlify('ed81da94d21ad1b305133f2726cdaec5a57fed98') - self.tdb.updateTorrent(m_infohash, relevance=3.1415926, category=u'Videoclips', - status=u'good', seeder=123, leecher=321, - last_tracker_check=1234567, - other_key1='abcd', other_key2=123) - multiple_torrent_id = self.tdb.getTorrentID(m_infohash) - category = self.tdb.getOne('category', torrent_id=multiple_torrent_id) - self.assertEqual(category, u'Videoclips') - status = self.tdb.getOne('status', torrent_id=multiple_torrent_id) - self.assertEqual(status, u'good') - seeder = self.tdb.getOne('num_seeders', torrent_id=multiple_torrent_id) - self.assertEqual(seeder, 123) - leecher = self.tdb.getOne('num_leechers', torrent_id=multiple_torrent_id) - self.assertEqual(leecher, 321) - last_tracker_check = self.tdb.getOne('last_tracker_check', torrent_id=multiple_torrent_id) - self.assertEqual(last_tracker_check, 1234567) - - def setUpPreSession(self): - super(TestTorrentDBHandler, self).setUpPreSession() - self.config.set_megacache_enabled(True) - self.config.set_torrent_store_enabled(True) - - @inlineCallbacks - def setUp(self): - yield super(TestTorrentDBHandler, self).setUp() - - from Tribler.Core.APIImplementation.LaunchManyCore import TriblerLaunchMany - from Tribler.Core.Modules.tracker_manager import TrackerManager - self.session.lm = TriblerLaunchMany() - self.session.lm.tracker_manager = TrackerManager(self.session) - self.tdb = TorrentDBHandler(self.session) - self.tdb.torrent_dir = TESTS_DATA_DIR - self.tdb.category = Category() - self.tdb.mypref_db = MyPreferenceDBHandler(self.session) - - @inlineCallbacks - def tearDown(self): - self.tdb.mypref_db.close() - self.tdb.mypref_db = None - self.tdb.close() - self.tdb = None - - yield super(TestTorrentDBHandler, self).tearDown() - - def test_hasTorrent(self): - infohash_str = 'AA8cTG7ZuPsyblbRE7CyxsrKUCg=' - infohash = str2bin(infohash_str) - self.assertTrue(self.tdb.hasTorrent(infohash)) - self.assertTrue(self.tdb.hasTorrent(infohash)) # cache will trigger - fake_infohash = 'fake_infohash_100000' - self.assertFalse(self.tdb.hasTorrent(fake_infohash)) - - def test_get_infohash(self): - self.assertTrue(self.tdb.getInfohash(1)) - self.assertFalse(self.tdb.getInfohash(1234567)) - - def test_add_update_torrent(self): - self.addTorrent() - self.updateTorrent() - - def test_update_torrent_from_metainfo(self): - # Add torrent first - infohash = unhexlify('ed81da94d21ad1b305133f2726cdaec5a57fed98') - # Only infohash is added to the database - self.tdb.addOrGetTorrentID(infohash) - - # Then update the torrent with metainfo - metainfo = {'info': {'files': [{'path': ['Something.something.pdf'], 'length': 123456789}, - {'path': ['Another-thing.jpg'], 'length': 100000000}], - 'piece length': 2097152, - 'name': '\xc3Something awesome (2015)', - 'pieces': ''}, - 'seeders': 0, 'initial peers': [], - 'leechers': 36, 'download_exists': False, 'nodes': []} - self.tdb.update_torrent_with_metainfo(infohash, metainfo) - - # Check updates are correct - torrent_id = self.tdb.getTorrentID(infohash) - name = self.tdb.getOne('name', torrent_id=torrent_id) - self.assertEqual(name, u'\xc3Something awesome (2015)') - num_files = self.tdb.getOne('num_files', torrent_id=torrent_id) - self.assertEqual(num_files, 2) - length = self.tdb.getOne('length', torrent_id=torrent_id) - self.assertEqual(length, 223456789) - - def test_add_external_torrent_no_def_existing(self): - infohash = str2bin('AA8cTG7ZuPsyblbRE7CyxsrKUCg=') - self.tdb.addExternalTorrentNoDef(infohash, "test torrent", [], [], 1234) - self.assertTrue(self.tdb.hasTorrent(infohash)) - - def test_add_external_torrent_no_def_no_files(self): - infohash = unhexlify('48865489ac16e2f34ea0cd3043cfd970cc24ec09') - self.tdb.addExternalTorrentNoDef(infohash, "test torrent", [], [], 1234) - self.assertFalse(self.tdb.hasTorrent(infohash)) - - def test_add_external_torrent_no_def_one_file(self): - infohash = unhexlify('49865489ac16e2f34ea0cd3043cfd970cc24ec09') - self.tdb.addExternalTorrentNoDef(infohash, "test torrent", [("file1", 42)], - ['http://localhost/announce'], 1234) - self.assertTrue(self.tdb.getTorrentID(infohash)) - - def test_add_external_torrent_no_def_more_files(self): - infohash = unhexlify('50865489ac16e2f34ea0cd3043cfd970cc24ec09') - self.tdb.addExternalTorrentNoDef(infohash, "test torrent", [("file1", 42), ("file2", 43)], - [], 1234, extra_info={"seeder": 2, "leecher": 3}) - self.assertTrue(self.tdb.getTorrentID(infohash)) - - def test_add_external_torrent_no_def_invalid(self): - infohash = unhexlify('50865489ac16e2f34ea0cd3043cfd970cc24ec09') - self.tdb.addExternalTorrentNoDef(infohash, "test torrent", [("file1", {}), ("file2", 43)], - [], 1234) - self.assertFalse(self.tdb.getTorrentID(infohash)) - - def test_add_get_torrent_id(self): - infohash = str2bin('AA8cTG7ZuPsyblbRE7CyxsrKUCg=') - self.assertEqual(self.tdb.addOrGetTorrentID(infohash), 1) - - new_infohash = unhexlify('50865489ac16e2f34ea0cd3043cfd970cc24ec09') - self.assertEqual(self.tdb.addOrGetTorrentID(new_infohash), 4859) - - def test_add_get_torrent_ids_return(self): - infohash = str2bin('AA8cTG7ZuPsyblbRE7CyxsrKUCg=') - new_infohash = unhexlify('50865489ac16e2f34ea0cd3043cfd970cc24ec09') - tids, inserted = self.tdb.addOrGetTorrentIDSReturn([infohash, new_infohash]) - self.assertEqual(tids, [1, 4859]) - self.assertEqual(len(inserted), 1) - - def test_index_torrent_existing(self): - self.tdb._indexTorrent(1, "test", []) - - def test_getCollectedTorrentHashes(self): - res = self.tdb.getNumberCollectedTorrents() - self.assertEqual(res, 4847) - - def test_freeSpace(self): - # Manually set the torrent store because register is not called. - self.session.lm.torrent_store = LevelDbStore(self.session.config.get_torrent_store_dir()) - old_res = self.tdb.getNumberCollectedTorrents() - self.tdb.freeSpace(20) - res = self.tdb.getNumberCollectedTorrents() - self.session.lm.torrent_store.close() - self.assertEqual(res, old_res - 20) - - def test_get_search_suggestions(self): - self.assertEqual(self.tdb.getSearchSuggestion(["content", "cont"]), ["content 1"]) - - def test_get_autocomplete_terms(self): - self.assertEqual(len(self.tdb.getAutoCompleteTerms("content", 100)), 0) - - def test_get_recently_randomly_collected_torrents(self): - self.assertEqual(len(self.tdb.getRecentlyCollectedTorrents(limit=10)), 10) - self.assertEqual(len(self.tdb.getRandomlyCollectedTorrents(100000000, limit=10)), 3) - - def test_get_recently_checked_torrents(self): - self.assertEqual(len(self.tdb.getRecentlyCheckedTorrents(limit=5)), 5) - - def test_select_torrents_to_collect(self): - infohash = str2bin('AA8cTG7ZuPsyblbRE7CyxsrKUCg=') - self.assertEqual(len(self.tdb.select_torrents_to_collect(infohash)), 0) - - def test_get_torrents_stats(self): - self.assertEqual(self.tdb.getTorrentsStats(), (4847, 6519179841442, 187195)) - - def test_get_library_torrents(self): - self.assertEqual(len(self.tdb.getLibraryTorrents(['infohash'])), 12) - - def test_search_names_no_sort(self): - """ - Test whether the right amount of torrents are returned when searching for torrents in db - """ - columns = ['T.torrent_id', 'infohash', 'status', 'num_seeders'] - self.tdb.channelcast_db = ChannelCastDBHandler(self.session) - self.assertEqual(len(self.tdb.searchNames(['content'], keys=columns, doSort=False)), 4849) - self.assertEqual(len(self.tdb.searchNames(['content', '1'], keys=columns, doSort=False)), 1) - - def test_search_names_sort(self): - """ - Test whether the right amount of sorted torrents are returned when searching for torrents in db - """ - columns = ['T.torrent_id', 'infohash', 'status', 'num_seeders'] - self.tdb.channelcast_db = ChannelCastDBHandler(self.session) - results = self.tdb.searchNames(['content'], keys=columns) - self.assertEqual(len(results), 4849) - self.assertEqual(results[0][3], 493785) - - def test_search_local_torrents(self): - """ - Test the search procedure in the local database when searching for torrents - """ - results = self.tdb.search_in_local_torrents_db('content', ['infohash', 'num_seeders'], family_filter=False, - last=5000) - self.assertEqual(len(results), 4849) - self.assertNotEqual(results[0][-1], 0.0) # Relevance score of result should not be zero - results = self.tdb.search_in_local_torrents_db('fdsafasfds', ['infohash']) - self.assertEqual(len(results), 0) - - def test_rel_score_remote_torrent(self): - self.tdb.latest_matchinfo_torrent = struct.pack("I" * 12, *([1] * 12)), u"torrent" - self.assertNotEqual(self.tdb.relevance_score_remote_torrent("\xe2my-torrent.iso"), 0.0) - - self.tdb.latest_matchinfo_torrent = struct.pack("I" * 12, *([1] * 12)), "torrent" - self.assertNotEqual(self.tdb.relevance_score_remote_torrent(u"my-torrent.iso"), 0.0) diff --git a/Tribler/Test/Core/test_sqlitecachedbhandler_votecasts.py b/Tribler/Test/Core/test_sqlitecachedbhandler_votecasts.py deleted file mode 100644 index e91451f22df..00000000000 --- a/Tribler/Test/Core/test_sqlitecachedbhandler_votecasts.py +++ /dev/null @@ -1,82 +0,0 @@ -from twisted.internet.defer import inlineCallbacks - -from Tribler.Core.CacheDB.SqliteCacheDBHandler import VoteCastDBHandler, ChannelCastDBHandler -from Tribler.Test.Core.test_sqlitecachedbhandler import AbstractDB - - -class TestVotecastDBHandler(AbstractDB): - - @inlineCallbacks - def setUp(self): - yield super(TestVotecastDBHandler, self).setUp() - - self.cdb = ChannelCastDBHandler(self.session) - self.vdb = VoteCastDBHandler(self.session) - self.vdb.channelcast_db = self.cdb - - def tearDown(self): - self.cdb.close() - self.cdb = None - self.vdb.close() - self.vdb = None - - super(TestVotecastDBHandler, self).tearDown() - - def test_on_votes_from_dispersy(self): - self.vdb.my_votes = {} - votes = [[1, None, 1, 2, 12345], [1, None, 2, -1, 12346], [2, 3, 2, -1, 12347]] - self.vdb.on_votes_from_dispersy(votes) - self.vdb._flush_to_database() - self.assertEqual(self.vdb.getPosNegVotes(1), (3, 1)) - - self.vdb.my_votes = None - votes = [[4, None, 1, 2, 12346]] - self.vdb.on_votes_from_dispersy(votes) - self.assertEqual(self.vdb.updatedChannels, {4}) - - def test_on_remove_votes_from_dispersy(self): - remove_votes = [[12345, 2, 3]] - self.vdb.on_remove_votes_from_dispersy(remove_votes, False) - self.assertEqual(self.vdb.updatedChannels, {2}) - remove_votes = [[12345, 2, 3], [12346, 1, 3]] - self.vdb.on_remove_votes_from_dispersy(remove_votes, True) - - def test_flush_to_database(self): - self.assertEqual(self.vdb.getPosNegVotes(1), (7, 5)) - self.vdb.updatedChannels = {1} - self.vdb._flush_to_database() - self.assertEqual(self.vdb.getPosNegVotes(1), (2, 0)) - self.vdb.updatedChannels = {} - self.vdb._flush_to_database() - - def test_get_latest_vote_dispersy_id(self): - self.assertEqual(self.vdb.get_latest_vote_dispersy_id(2, 5), 3) - self.assertEqual(self.vdb.get_latest_vote_dispersy_id(1, None), 3) - - def test_get_pos_neg_votes(self): - self.assertEqual(self.vdb.getPosNegVotes(1), (7, 5)) - self.assertEqual(self.vdb.getPosNegVotes(2), (93, 83)) - self.assertEqual(self.vdb.getPosNegVotes(42), (0, 0)) - - def test_get_vote_on_channel(self): - self.assertEqual(self.vdb.getVoteOnChannel(3, 6), -1) - self.assertEqual(self.vdb.getVoteOnChannel(4, None), -1) - - def test_get_vote_for_my_channel(self): - self.vdb.channelcast_db._channel_id = 1 - self.assertEqual(self.vdb.getVoteForMyChannel(6), 2) - - def test_get_dispersy_id(self): - self.assertEqual(self.vdb.getDispersyId(2, 5), 3) - self.assertEqual(self.vdb.getDispersyId(2, None), 3) - - def test_get_timestamp(self): - self.assertEqual(self.vdb.getTimestamp(2, 5), 8440) - self.assertEqual(self.vdb.getTimestamp(2, None), 8439) - - def test_get_my_votes(self): - my_votes = self.vdb.getMyVotes() - self.assertEqual(my_votes, {1: 2, 2: -1, 4: -1}) - self.assertIsNotNone(self.vdb.my_votes) - my_votes = self.vdb.getMyVotes() - self.assertEqual(my_votes, {1: 2, 2: -1, 4: -1}) diff --git a/Tribler/Test/mocking/__init__.py b/Tribler/Test/mocking/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/Tribler/Test/mocking/channel.py b/Tribler/Test/mocking/channel.py deleted file mode 100644 index 34b50793ab2..00000000000 --- a/Tribler/Test/mocking/channel.py +++ /dev/null @@ -1,17 +0,0 @@ -class MockChannel(object): - - def __init__(self, infohash, public_key, title, version, votes=0, local_version=0): - self.infohash = infohash - self.public_key = public_key - self.title = title - self.version = version - self.votes = votes - self.local_version = local_version - - self.random_torrents = None - - def set_random_torrents(self, torrents_list): - self.random_torrents = torrents_list - - def get_random_torrents(self, limit): - return self.random_torrents diff --git a/Tribler/Test/mocking/download.py b/Tribler/Test/mocking/download.py deleted file mode 100644 index 645b57d105c..00000000000 --- a/Tribler/Test/mocking/download.py +++ /dev/null @@ -1,17 +0,0 @@ -class MockDownload(object): - - class MockTdef(object): - - def __init__(self): - self.infohash = "" - - def set_infohash(self, infohash): - self.infohash = infohash - - def get_infohash(self): - return self.infohash - - tdef = MockTdef() - - def get_num_connected_seeds_peers(self): - return 42, 1337 diff --git a/Tribler/Test/mocking/session.py b/Tribler/Test/mocking/session.py deleted file mode 100644 index e0f17270b3a..00000000000 --- a/Tribler/Test/mocking/session.py +++ /dev/null @@ -1,67 +0,0 @@ -from twisted.internet.defer import Deferred - -from .channel import MockChannel - - -class MockSession(object): - - class MockLm(object): - - class MockMds(object): - - class MockChannelMetadata(object): - - def __init__(self): - self.random_channels = [] - self.channel_with_infohash = {} - self.channel_with_id = {} - - def set_random_channels(self, channel_list): - self.random_channels = channel_list - - def get_random_subscribed_channels(self, _): - return self.random_channels - - def add(self, channel): - self.channel_with_infohash[channel.infohash] = channel - self.channel_with_id[channel.public_key] = channel - - def get_channel_with_infohash(self, infohash): - return self.channel_with_infohash.get(infohash, None) - - def get_channel_with_id(self, public_key): - return self.channel_with_id.get(public_key, None) - - def from_dict(self, dictionary): - return MockChannel(**dictionary) - - ChannelMetadata = MockChannelMetadata() - - mds = MockMds() - - def __init__(self): - self.downloaded_channel = None - self.downloaded_channel_deferred = Deferred() - self.downloading = False - - def set_download_channel(self, download): - self.downloaded_channel = download - - def finish_download_channel(self): - self.downloading = False - self.downloaded_channel_deferred.callback(self.downloaded_channel) - - def download_channel(self, channel): - self.downloading = True - return self.downloaded_channel, self.downloaded_channel_deferred - - lm = MockLm() - - def __init__(self): - self.known_infohashes = [] - - def add_known_infohash(self, infohash): - self.known_infohashes.append(infohash) - - def has_download(self, infohash): - return infohash in self.known_infohashes diff --git a/Tribler/Test/test_as_server.py b/Tribler/Test/test_as_server.py index 699b99d006a..f760a9d890c 100644 --- a/Tribler/Test/test_as_server.py +++ b/Tribler/Test/test_as_server.py @@ -4,6 +4,7 @@ Author(s): Arno Bakker, Jie Yang, Niels Zeilemaker """ from __future__ import absolute_import + import functools import inspect import logging @@ -16,13 +17,15 @@ from threading import enumerate as enumerate_threads from configobj import ConfigObj + import six from six.moves import xrange + import twisted from twisted.internet import interfaces from twisted.internet import reactor from twisted.internet.base import BasePort -from twisted.internet.defer import maybeDeferred, inlineCallbacks, Deferred, succeed +from twisted.internet.defer import Deferred, inlineCallbacks, maybeDeferred, succeed from twisted.internet.task import deferLater from twisted.internet.tcp import Client from twisted.trial import unittest @@ -30,13 +33,13 @@ from twisted.web.server import Site from twisted.web.static import File -from Tribler.Core.Config.tribler_config import TriblerConfig, CONFIG_SPEC_PATH +from Tribler.Core.Config.tribler_config import CONFIG_SPEC_PATH, TriblerConfig from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Session import Session from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.instrumentation import WatchDog from Tribler.Core.Utilities.network_utils import get_random_port -from Tribler.Core.simpledefs import dlstatus_strings, DLSTATUS_SEEDING +from Tribler.Core.simpledefs import DLSTATUS_SEEDING, dlstatus_strings from Tribler.Test.util.util import process_unhandled_exceptions, process_unhandled_twisted_exceptions TESTS_DIR = os.path.abspath(os.path.dirname(os.path.realpath(__file__))) @@ -286,17 +289,10 @@ def setUpPreSession(self): self.config.set_default_destination_dir(self.dest_dir) self.config.set_state_dir(self.getStateDir()) self.config.set_torrent_checking_enabled(False) - self.config.set_megacache_enabled(False) - self.config.set_dispersy_enabled(False) self.config.set_ipv8_enabled(False) self.config.set_mainline_dht_enabled(False) - self.config.set_torrent_store_enabled(False) - self.config.set_torrent_search_enabled(False) - self.config.set_channel_search_enabled(False) - self.config.set_torrent_collecting_enabled(False) self.config.set_libtorrent_enabled(False) self.config.set_video_server_enabled(False) - self.config.set_metadata_enabled(False) self.config.set_http_api_enabled(False) self.config.set_tunnel_community_enabled(False) self.config.set_credit_mining_enabled(False) @@ -349,18 +345,11 @@ def create_local_torrent(self, source_file): def setup_seeder(self, tdef, seed_dir, port=None): self.seed_config = TriblerConfig() self.seed_config.set_torrent_checking_enabled(False) - self.seed_config.set_megacache_enabled(False) - self.seed_config.set_dispersy_enabled(False) self.seed_config.set_ipv8_enabled(False) self.seed_config.set_mainline_dht_enabled(False) - self.seed_config.set_torrent_store_enabled(False) - self.seed_config.set_torrent_search_enabled(False) - self.seed_config.set_channel_search_enabled(False) self.seed_config.set_http_api_enabled(False) - self.seed_config.set_torrent_collecting_enabled(False) self.seed_config.set_libtorrent_enabled(True) self.seed_config.set_video_server_enabled(False) - self.seed_config.set_metadata_enabled(False) self.seed_config.set_tunnel_community_enabled(False) self.seed_config.set_market_community_enabled(False) self.seed_config.set_dht_enabled(False) diff --git a/Tribler/Test/util/Tracker/TrackerInfo.py b/Tribler/Test/util/Tracker/TrackerInfo.py index 3f2d3c3a525..61aa1bd4bf6 100644 --- a/Tribler/Test/util/Tracker/TrackerInfo.py +++ b/Tribler/Test/util/Tracker/TrackerInfo.py @@ -2,6 +2,7 @@ Keeping track of information about a tracker. """ + class TrackerInfo(object): """ This class keeps track of info about a tracker. This info is used when a request to a tracker is performed. diff --git a/Tribler/community/allchannel/__init__.py b/Tribler/community/allchannel/__init__.py deleted file mode 100644 index 7daf1e9839c..00000000000 --- a/Tribler/community/allchannel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -The allchannel community is used to collect votes for channels and thereby discover which channels are most popular. -""" diff --git a/Tribler/community/allchannel/community.py b/Tribler/community/allchannel/community.py deleted file mode 100644 index 591a8f7192c..00000000000 --- a/Tribler/community/allchannel/community.py +++ /dev/null @@ -1,698 +0,0 @@ -from random import sample -from time import time -from twisted.internet.defer import returnValue, inlineCallbacks -from twisted.internet.task import LoopingCall -from twisted.python.threadable import isInIOThread - -from Tribler.community.allchannel.message import DelayMessageReqChannelMessage -from Tribler.community.allchannel.payload import (ChannelCastRequestPayload, ChannelCastPayload, VoteCastPayload, - ChannelSearchPayload, ChannelSearchResponsePayload) -from Tribler.community.channel.community import ChannelCommunity -from Tribler.community.channel.preview import PreviewChannelCommunity -from Tribler.dispersy.authentication import MemberAuthentication -from Tribler.dispersy.community import Community -from Tribler.dispersy.conversion import DefaultConversion -from Tribler.dispersy.database import IgnoreCommits -from Tribler.dispersy.destination import CandidateDestination, CommunityDestination -from Tribler.dispersy.distribution import FullSyncDistribution, DirectDistribution -from Tribler.dispersy.exception import CommunityNotFoundException -from Tribler.dispersy.message import Message, BatchConfiguration -from Tribler.dispersy.resolution import PublicResolution -from .conversion import AllChannelConversion - -CHANNELCAST_FIRST_MESSAGE = 3.0 -CHANNELCAST_INTERVAL = 15.0 -CHANNELCAST_BLOCK_PERIOD = 10.0 * 60.0 # block for 10 minutes -UNLOAD_COMMUNITY_INTERVAL = 60.0 - -DEBUG = False - - -class AllChannelCommunity(Community): - """ - A single community that all Tribler members join and use to disseminate .torrent files. - - The dissemination of .torrent files, using 'community-propagate' messages, is NOT done using a - dispersy sync mechanism. We prefer more specific dissemination mechanism than dispersy - provides. Dissemination occurs by periodically sending: - - - N most recently received .torrent files - - M random .torrent files - - O most recent .torrent files, created by ourselves - - P randomly choosen .torrent files, created by ourselves - """ - @classmethod - def get_master_members(cls, dispersy): -# generated: Fri Nov 25 10:51:27 2011 -# curve: high <<< NID_sect571r1 >>> -# len: 571 bits ~ 144 bytes signature -# pub: 170 3081a7301006072a8648ce3d020106052b81040027038192000405548a13626683d4788ab19393fa15c9e9d6f5ce0ff47737747fa511af6c4e956f523dc3d1ae8d7b83b850f21ab157dd4320331e2f136aa01e70d8c96df665acd653725e767da9b5079f25cebea808832cd16015815797906e90753d135ed2d796b9dfbafaf1eae2ebea3b8846716c15814e96b93ae0f5ffaec44129688a38ea35f879205fdbe117323e73076561f112 -# pub-sha1 8164f55c2f828738fa779570e4605a81fec95c9d -# -----BEGIN PUBLIC KEY----- -# MIGnMBAGByqGSM49AgEGBSuBBAAnA4GSAAQFVIoTYmaD1HiKsZOT+hXJ6db1zg/0 -# dzd0f6URr2xOlW9SPcPRro17g7hQ8hqxV91DIDMeLxNqoB5w2Mlt9mWs1lNyXnZ9 -# qbUHnyXOvqgIgyzRYBWBV5eQbpB1PRNe0teWud+6+vHq4uvqO4hGcWwVgU6WuTrg -# 9f+uxEEpaIo46jX4eSBf2+EXMj5zB2Vh8RI= -# -----END PUBLIC KEY----- - master_key = "3081a7301006072a8648ce3d020106052b81040027038192000405548a13626683d4788ab19393fa15c9e9d6f5ce0ff47737747fa511af6c4e956f523dc3d1ae8d7b83b850f21ab157dd4320331e2f136aa01e70d8c96df665acd653725e767da9b5079f25cebea808832cd16015815797906e90753d135ed2d796b9dfbafaf1eae2ebea3b8846716c15814e96b93ae0f5ffaec44129688a38ea35f879205fdbe117323e73076561f112".decode("HEX") - master = dispersy.get_member(public_key=master_key) - return [master] - - @property - def dispersy_sync_bloom_filter_strategy(self): - return self._dispersy_claim_sync_bloom_filter_modulo - - def initiate_meta_messages(self): - batch_delay = 1.0 - - return super(AllChannelCommunity, self).initiate_meta_messages() + [ - Message(self, u"channelcast", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - ChannelCastPayload(), - self.check_channelcast, - self.on_channelcast), - Message(self, u"channelcast-request", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - ChannelCastRequestPayload(), - self.check_channelcast_request, - self.on_channelcast_request), - Message(self, u"channelsearch", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CommunityDestination(node_count=10), - ChannelSearchPayload(), - self.check_channelsearch, - self.on_channelsearch), - Message(self, u"channelsearch-response", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - ChannelSearchResponsePayload(), - self.check_channelsearch_response, - self.on_channelsearch_response), - Message(self, u"votecast", - MemberAuthentication(), - PublicResolution(), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - VoteCastPayload(), - self.check_votecast, - self.on_votecast, - self.undo_votecast, - batch=BatchConfiguration(max_window=batch_delay)) - ] - - def __init__(self, *args, **kwargs): - super(AllChannelCommunity, self).__init__(*args, **kwargs) - - self._blocklist = {} - self._recentlyRequested = [] - - self.tribler_session = None - self.auto_join_channel = None - - self._channelcast_db = None - self._votecast_db = None - self._peer_db = None - - def initialize(self, tribler_session=None, auto_join_channel=False): - super(AllChannelCommunity, self).initialize() - - self.tribler_session = tribler_session - self.auto_join_channel = auto_join_channel - - if tribler_session is not None: - from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_VOTECAST, NTFY_PEERS - - # tribler channelcast database - self._channelcast_db = tribler_session.open_dbhandler(NTFY_CHANNELCAST) - self._votecast_db = tribler_session.open_dbhandler(NTFY_VOTECAST) - self._peer_db = tribler_session.open_dbhandler(NTFY_PEERS) - - else: - self._channelcast_db = ChannelCastDBStub(self._dispersy) - self._votecast_db = VoteCastDBStub(self._dispersy) - self._peer_db = PeerDBStub(self._dispersy) - - self.register_task(u"channelcast", - LoopingCall(self.create_channelcast)).start(CHANNELCAST_FIRST_MESSAGE, now=True) - - self.register_task(u"unload preview", - LoopingCall(self.unload_preview)).start(UNLOAD_COMMUNITY_INTERVAL, now=False) - - def initiate_conversions(self): - return [DefaultConversion(self), AllChannelConversion(self)] - - @property - def dispersy_auto_download_master_member(self): - # there is no dispersy-identity for the master member, so don't try to download - return False - - @property - def dispersy_sync_response_limit(self): - return 25 * 1024 - - def create_channelcast(self): - assert isInIOThread() - now = time() - - favoriteTorrents = None - normalTorrents = None - - # cleanup blocklist - for candidate in self._blocklist.keys(): - if self._blocklist[candidate] + CHANNELCAST_BLOCK_PERIOD < now: # unblock address - self._blocklist.pop(candidate) - - mychannel_id = self._channelcast_db.getMyChannelId() - - # loop through all candidates to see if we can find a non-blocked address - for candidate in [candidate for candidate in self._iter_categories([u'walk', u'stumble'], once=True) if candidate not in self._blocklist]: - if not candidate: - continue - - didFavorite = False - # only check if we actually have a channel - if mychannel_id: - peer_ids = set() - key = candidate.get_member().public_key - peer_ids.add(self._peer_db.addOrGetPeerID(key)) - - # see if all members on this address are subscribed to my channel - didFavorite = len(peer_ids) > 0 - for peer_id in peer_ids: - vote = self._votecast_db.getVoteForMyChannel(peer_id) - if vote != 2: - didFavorite = False - break - - # Modify type of message depending on if all peers have marked my channels as their favorite - if didFavorite: - if not favoriteTorrents: - favoriteTorrents = self._channelcast_db.getRecentAndRandomTorrents(0, 0, 25, 25, 5) - torrents = favoriteTorrents - else: - if not normalTorrents: - normalTorrents = self._channelcast_db.getRecentAndRandomTorrents() - torrents = normalTorrents - - # torrents is a dictionary of channel_id (key) and infohashes (value) - if len(torrents) > 0: - meta = self.get_meta_message(u"channelcast") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), payload=(torrents,)) - - self._dispersy._forward([message]) - - # we've send something to this address, add to blocklist - self._blocklist[candidate] = now - - nr_torrents = sum(len(infohashes) for infohashes in torrents.itervalues()) - self._logger.debug("sending channelcast message containing %s torrents to %s didFavorite %s", - nr_torrents, candidate.sock_addr, didFavorite) - # we're done - break - - else: - self._logger.debug("Did not send channelcast messages, no candidates or torrents") - - def get_nr_connections(self): - return len(list(self.dispersy_yield_candidates())) - - def check_channelcast(self, messages): - with self._dispersy.database: - for message in messages: - for cid in message.payload.torrents.iterkeys(): - channel_id = self._get_channel_id(cid) - if not channel_id: - community = self._get_channel_community(cid) - yield DelayMessageReqChannelMessage(message, community, includeSnapshot=True) - break - else: - yield message - - # ensure that no commits occur - raise IgnoreCommits() - - def on_channelcast(self, messages): - for message in messages: - toCollect = {} - for cid, infohashes in message.payload.torrents.iteritems(): - for infohash in self._selectTorrentsToCollect(cid, infohashes): - toCollect.setdefault(cid, set()).add(infohash) - - nr_requests = sum([len(infohashes) for infohashes in toCollect.values()]) - if nr_requests > 0: - self.create_channelcast_request(toCollect, message.candidate) - - def create_channelcast_request(self, toCollect, candidate): - # create channelcast request message - meta = self.get_meta_message(u"channelcast-request") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), payload=(toCollect,)) - self._dispersy._forward([message]) - - nr_requests = sum([len(torrents) for torrents in toCollect.itervalues()]) - self._logger.debug("requesting %s torrents from %s", nr_requests, candidate) - - def check_channelcast_request(self, messages): - # no timeline check because PublicResolution policy is used - return messages - - def on_channelcast_request(self, messages): - for message in messages: - requested_packets = [] - for cid, infohashes in message.payload.torrents.iteritems(): - requested_packets.extend(self._get_packets_from_infohashes(cid, infohashes)) - - if requested_packets: - self._dispersy._send_packets([message.candidate], requested_packets, - self, "-caused by channelcast-request-") - - self._logger.debug("got request for %s torrents from %s", len(requested_packets), message.candidate) - - def create_channelsearch(self, keywords): - # clear searchcallbacks if new search - query = " ".join(keywords) - - meta = self.get_meta_message(u"channelsearch") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), - payload=(keywords,)) - - self._logger.debug("searching for channel matching '%s'", query) - - return self._dispersy._forward([message]) - - def check_channelsearch(self, messages): - # no timeline check because PublicResolution policy is used - return messages - - def on_channelsearch(self, messages): - for message in messages: - keywords = message.payload.keywords - query = " ".join(keywords) - - self._logger.debug("got search request for '%s'", query) - - results = self._channelcast_db.searchChannelsTorrent(query, 7, 7, dispersyOnly=True) - if len(results) > 0: - responsedict = {} - for channel_id, dispersy_cid, name, infohash, torname, time_stamp in results: - infohashes = responsedict.setdefault(dispersy_cid, set()) - infohashes.add(infohash) - - self._logger.debug("found cid: %s infohash: %s", dispersy_cid.encode("HEX"), infohash.encode("HEX")) - - self.create_channelsearch_response(keywords, responsedict, message.candidate) - - else: - self._logger.debug("no results") - - def create_channelsearch_response(self, keywords, torrents, candidate): - # create channelsearch-response message - meta = self.get_meta_message(u"channelsearch-response") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), payload=(keywords, torrents)) - - self._dispersy._forward([message]) - - nr_requests = sum([len(tors) for tors in torrents.values()]) - self._logger.debug("sending %s results", nr_requests) - - def check_channelsearch_response(self, messages): - with self._dispersy.database: - for message in messages: - for cid in message.payload.torrents.iterkeys(): - channel_id = self._get_channel_id(cid) - if not channel_id: - community = self._get_channel_community(cid) - yield DelayMessageReqChannelMessage(message, community, includeSnapshot=True) - break - else: - yield message - - # ensure that no commits occur - raise IgnoreCommits() - - def on_channelsearch_response(self, messages): - # request missing torrents - self.on_channelcast(messages) - - for message in messages: - # show results in gui - keywords = message.payload.keywords - query = " ".join(keywords) - - self._logger.debug("got search response for '%s'", query) - - # emit a results signal if integrated with Tribler - if self.tribler_session is not None: - from Tribler.Core.simpledefs import SIGNAL_ALLCHANNEL_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS - torrents = message.payload.torrents - results = {'keywords': keywords, - 'torrents': torrents} - self.tribler_session.notifier.notify(SIGNAL_ALLCHANNEL_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS, None, results) - - @inlineCallbacks - def disp_create_votecast(self, cid, vote, timestamp, store=True, update=True, forward=True): - # reclassify community - if vote == 2: - communityclass = ChannelCommunity - else: - communityclass = PreviewChannelCommunity - - community_old = self._get_channel_community(cid) - community = yield self.dispersy.reclassify_community(community_old, communityclass) - community._candidates = community_old._candidates - - # check if we need to cancel a previous vote - latest_dispersy_id = self._votecast_db.get_latest_vote_dispersy_id(community._channel_id, None) - if latest_dispersy_id: - message = self._dispersy.load_message_by_packetid(self, latest_dispersy_id) - if message: - self.create_undo(message) - - # create new vote message - meta = self.get_meta_message(u"votecast") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.claim_global_time(),), - payload=(cid, vote, timestamp)) - self._dispersy.store_update_forward([message], store, update, forward) - - returnValue(message) - - def check_votecast(self, messages): - with self._dispersy.database: - communities = {} - channel_ids = {} - for cid in set([message.payload.cid for message in messages]): - channel_id = self._get_channel_id(cid) - if channel_id: - channel_ids[cid] = channel_id - else: - communities[cid] = self._get_channel_community(cid) - - for message in messages: - community = communities.get(message.payload.cid) - if community: - # at this point we should NOT have the channel message for this community - if __debug__: - try: - self._dispersy.database.execute( - u"SELECT * FROM sync WHERE community = ? AND meta_message = ? AND undone = 0", - (community.database_id, community.get_meta_message(u"channel").database_id)).next() - self._logger.error("We already have the channel message... no need to wait for it %s", - community.cid.encode("HEX")) - except StopIteration: - pass - - self._logger.debug("Did not receive channel, requesting channel message '%s' from %s", - community.cid.encode("HEX"), message.candidate.sock_addr) - # request torrents if positive vote - yield DelayMessageReqChannelMessage(message, community, includeSnapshot=message.payload.vote > 0) - - else: - message.channel_id = channel_ids[message.payload.cid] - yield message - - # ensure that no commits occur - raise IgnoreCommits() - - def on_votecast(self, messages): - if self.tribler_session is not None: - votelist = [] - for message in messages: - dispersy_id = message.packet_id - channel_id = getattr(message, "channel_id", 0) - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - - # if channel_id is not found, then this is a manual join - # insert placeholder into database which will be replaced after channelmessage has been received - if not channel_id: - select_channel = "SELECT id FROM _Channels WHERE dispersy_cid = ?" - channel_id = self._channelcast_db._db.fetchone(select_channel, (buffer(message.payload.cid),)) - - if not channel_id: - insert_channel = "INSERT INTO _Channels (dispersy_cid, peer_id, name) " \ - "VALUES (?, ?, ?); SELECT last_insert_rowid();" - channel_id = self._channelcast_db._db.fetchone(insert_channel, - (buffer(message.payload.cid), -1, '')) - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - votelist.append((channel_id, peer_id, dispersy_id, message.payload.vote, message.payload.timestamp)) - - self._votecast_db.on_votes_from_dispersy(votelist) - - def undo_votecast(self, descriptors, redo=False): - if self.tribler_session is not None: - contains_my_vote = False - votelist = [] - now = long(time()) - for _, _, packet in descriptors: - message = packet.load_message() - dispersy_id = message.packet_id - - channel_id = self._get_channel_id(message.payload.cid) - votelist.append((None if redo else now, channel_id, dispersy_id)) - - authentication_member = message.authentication.member - my_vote = authentication_member == self._my_member - if my_vote: - contains_my_vote = True - - self._votecast_db.on_remove_votes_from_dispersy(votelist, contains_my_vote) - - def _get_channel_community(self, cid): - assert isinstance(cid, str) - assert len(cid) == 20 - - try: - return self._dispersy.get_community(cid, True) - except CommunityNotFoundException: - if self.auto_join_channel: - self._logger.info("join channel community %s", cid.encode("HEX")) - return ChannelCommunity.init_community(self._dispersy, self._dispersy.get_member(mid=cid), - self._my_member, tribler_session=self.tribler_session) - else: - self._logger.info("join preview community %s", cid.encode("HEX")) - return PreviewChannelCommunity.init_community(self._dispersy, self._dispersy.get_member(mid=cid), - self._my_member, tribler_session=self.tribler_session) - - @inlineCallbacks - def unload_preview(self): - cleanpoint = time() - 300 - inactive = [community for community in self.dispersy._communities.itervalues() if isinstance( - community, PreviewChannelCommunity) and community.init_timestamp < cleanpoint] - self._logger.debug("cleaning %d/%d previewchannel communities", len(inactive), len(self.dispersy._communities)) - - for community in inactive: - yield community.unload_community() - - def _get_channel_id(self, cid): - assert isinstance(cid, str) - assert len(cid) == 20 - - return self._channelcast_db.getChannelIdFromDispersyCID(buffer(cid)) - - def _selectTorrentsToCollect(self, cid, infohashes): - channel_id = self._get_channel_id(cid) - - row = self._channelcast_db.getCountMaxFromChannelId(channel_id) - if row: - nrTorrrents, latestUpdate = row - else: - nrTorrrents = 0 - latestUpdate = 0 - - collect = [] - - # filter infohashes using recentlyRequested - infohashes = filter(lambda infohash: infohash not in self._recentlyRequested, infohashes) - - # only request updates if nrT < 100 or we have not received an update in the last half hour - if nrTorrrents < 100 or latestUpdate < (time() - 1800): - infohashes = list(infohashes) - haveTorrents = self._channelcast_db.hasTorrents(channel_id, infohashes) - for i in range(len(infohashes)): - if not haveTorrents[i]: - collect.append(infohashes[i]) - - self._recentlyRequested.extend(collect) - self._recentlyRequested = self._recentlyRequested[:100] - - return collect - - def _get_packets_from_infohashes(self, cid, infohashes): - assert all(isinstance(infohash, str) for infohash in infohashes) - assert all(len(infohash) == 20 for infohash in infohashes) - - channel_id = self._get_channel_id(cid) - - packets = [] - for infohash in infohashes: - dispersy_id = self._channelcast_db.getTorrentFromChannelId( - channel_id, infohash, ['ChannelTorrents.dispersy_id']) - - if dispersy_id and dispersy_id > 0: - try: - # 2. get the message - packets.append(self._get_packet_from_dispersy_id(dispersy_id, "torrent")) - except RuntimeError: - pass - - return packets - - def _get_packet_from_dispersy_id(self, dispersy_id, messagename): - try: - packet, = self._dispersy.database.execute( - u"SELECT sync.packet FROM community JOIN sync ON sync.community = community.id WHERE sync.id = ?", (dispersy_id,)).next() - except StopIteration: - raise RuntimeError("Unknown dispersy_id") - return str(packet) - - -class ChannelCastDBStub(): - - def __init__(self, dispersy): - self._dispersy = dispersy - self.channel_id = None - self.mychannel = False - self.latest_result = 0 - - self.cachedTorrents = None - self.recentTorrents = [] - - def convert_to_messages(self, results): - messages = self._dispersy.convert_packets_to_messages(str(packet) for packet, _ in results) - for packet_id, message in zip((packet_id for _, packet_id in results), messages): - if message: - message.packet_id = packet_id - yield message.community.cid, message - - def getChannelIdFromDispersyCID(self, cid): - return self.channel_id - - def getCountMaxFromChannelId(self, channel_id): - if self.cachedTorrents: - return len(self.cachedTorrents), self.latest_result - - def getRecentAndRandomTorrents(self, NUM_OWN_RECENT_TORRENTS=15, NUM_OWN_RANDOM_TORRENTS=10, NUM_OTHERS_RECENT_TORRENTS=15, NUM_OTHERS_RANDOM_TORRENTS=10, NUM_OTHERS_DOWNLOADED=5): - torrent_dict = {} - - for _, payload in self.recentTorrents[:max(NUM_OWN_RECENT_TORRENTS, NUM_OTHERS_RECENT_TORRENTS)]: - torrent_dict.setdefault(self.channel_id, set()).add(payload.infohash) - - if len(self.recentTorrents) >= NUM_OWN_RECENT_TORRENTS: - for infohash in self.getRandomTorrents(self.channel_id, max(NUM_OWN_RANDOM_TORRENTS, NUM_OTHERS_RANDOM_TORRENTS)): - torrent_dict.setdefault(self.channel_id, set()).add(infohash) - - return torrent_dict - - def getRandomTorrents(self, channel_id, limit=15): - torrents = self._cachedTorrents.keys() - if len(torrents) > limit: - return sample(torrents, limit) - return torrents - - def newTorrent(self, message): - self._cachedTorrents[message.payload.infohash] = message - - self.recentTorrents.append((message.distribution.global_time, message.payload)) - self.recentTorrents.sort(reverse=True) - self.recentTorrents[:50] - - self.latest_result = time() - - def setChannelId(self, channel_id, mychannel): - self.channel_id = channel_id - self.mychannel = mychannel - - def getMyChannelId(self): - if self.mychannel: - return self.channel_id - - def hasTorrents(self, channel_id, infohashes): - returnAr = [] - for infohash in infohashes: - if infohash in self._cachedTorrents: - returnAr.append(True) - else: - returnAr.append(False) - return returnAr - - def getTorrentFromChannelId(self, channel_id, infohash, keys): - if infohash in self._cachedTorrents: - return self._cachedTorrents[infohash].packet_id - - def on_dynamic_settings(self, channel_id): - pass - - @property - def _cachedTorrents(self): - if self.cachedTorrents is None: - self.cachedTorrents = {} - self._cacheTorrents() - - return self.cachedTorrents - - def _cacheTorrents(self): - sql = u"SELECT sync.packet, sync.id FROM sync JOIN meta_message ON sync.meta_message = meta_message.id JOIN community ON community.id = sync.community WHERE meta_message.name = 'torrent'" - results = list(self._dispersy.database.execute(sql)) - messages = self.convert_to_messages(results) - - for _, message in messages: - self._cachedTorrents[message.payload.infohash] = message - self.recentTorrents.append((message.distribution.global_time, message.payload)) - - self.recentTorrents.sort(reverse=True) - self.recentTorrents[:50] - - -class VoteCastDBStub(): - - def __init__(self, dispersy): - self._dispersy = dispersy - self._votecache = {} - - def getDispersyId(self, cid, public_key): - if public_key in self._votecache: - return self._votecache[public_key] - - sql = u"SELECT sync.id FROM sync JOIN member ON sync.member = member.id JOIN community ON community.id = sync.community JOIN meta_message ON sync.meta_message = meta_message.id WHERE community.classification = 'AllChannelCommunity' AND meta_message.name = 'votecast' AND member.public_key = ? ORDER BY global_time DESC LIMIT 1" - try: - id, = self._dispersy.database.execute(sql, (buffer(public_key),)).next() - self._votecache[public_key] = int(id) - return self._votecache[public_key] - except StopIteration: - return - - def getVoteForMyChannel(self, public_key): - id = self.getDispersyId(None, public_key) - if id: # if we have a votecastmessage from this peer in our sync table, then signal a mark as favorite - return 2 - return 0 - - def get_latest_vote_dispersy_id(self, channel_id, voter_id): - return - - -class PeerDBStub(): - - def __init__(self, dispersy): - self._dispersy = dispersy - - def addOrGetPeerID(self, public_key): - return public_key diff --git a/Tribler/community/allchannel/conversion.py b/Tribler/community/allchannel/conversion.py deleted file mode 100644 index e103f61f427..00000000000 --- a/Tribler/community/allchannel/conversion.py +++ /dev/null @@ -1,130 +0,0 @@ -from random import choice, sample -from struct import pack, unpack_from - -from Tribler.dispersy.conversion import BinaryConversion -from Tribler.dispersy.message import DropPacket -from Tribler.pyipv8.ipv8.messaging.deprecated.encoding import encode, decode - - -class AllChannelConversion(BinaryConversion): - - def __init__(self, community): - super(AllChannelConversion, self).__init__(community, "\x01") - self.define_meta_message(chr(1), community.get_meta_message(u"channelcast"), - self._encode_channelcast, self._decode_channelcast) - self.define_meta_message(chr(2), community.get_meta_message(u"channelcast-request"), - self._encode_channelcast, self._decode_channelcast) - self.define_meta_message(chr(3), community.get_meta_message(u"channelsearch"), - self._encode_channelsearch, self._decode_channelsearch) - self.define_meta_message(chr(4), community.get_meta_message(u"channelsearch-response"), - self._encode_channelsearch_response, self._decode_channelsearch_response) - self.define_meta_message(chr(5), community.get_meta_message(u"votecast"), - self._encode_votecast, self._decode_votecast) - - def _encode_channelcast(self, message): - max_len = self._community.dispersy_sync_bloom_filter_bits / 8 - - def create_msg(): - return encode(message.payload.torrents) - - packet = create_msg() - while len(packet) > max_len: - community = choice(message.payload.torrents.keys()) - nrTorrents = len(message.payload.torrents[community]) - if nrTorrents == 1: - del message.payload.torrents[community] - else: - message.payload.torrents[community] = set(sample(message.payload.torrents[community], nrTorrents - 1)) - - packet = create_msg() - - return packet, - - def _decode_channelcast(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the channelcast-payload") - - if not isinstance(payload, dict): - raise DropPacket("Invalid payload type") - - for cid, infohashes in payload.iteritems(): - if not (isinstance(cid, str) and len(cid) == 20): - raise DropPacket("Invalid 'cid' type or value") - - for infohash in infohashes: - if not (isinstance(infohash, str) and len(infohash) == 20): - raise DropPacket("Invalid 'infohash' type or value") - return offset, placeholder.meta.payload.implement(payload) - - def _encode_channelsearch(self, message): - packet = encode(message.payload.keywords) - return packet, - - def _decode_channelsearch(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the channelcast-payload") - - if not isinstance(payload, list): - raise DropPacket("Invalid payload type") - - for keyword in payload: - if not isinstance(keyword, unicode): - raise DropPacket("Invalid 'keyword' type") - return offset, placeholder.meta.payload.implement(payload) - - def _encode_channelsearch_response(self, message): - packet = encode((message.payload.keywords, message.payload.torrents)) - return packet, - - def _decode_channelsearch_response(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the channelcast-payload") - - if not isinstance(payload, tuple): - raise DropPacket("Invalid payload type") - - keywords, torrents = payload - for keyword in keywords: - if not isinstance(keyword, unicode): - raise DropPacket("Invalid 'keyword' type") - - for cid, infohashes in torrents.iteritems(): - if not (isinstance(cid, str) and len(cid) == 20): - raise DropPacket("Invalid 'cid' type or value") - - for infohash in infohashes: - if not (isinstance(infohash, str) and len(infohash) == 20): - raise DropPacket("Invalid 'infohash' type or value") - - return offset, placeholder.meta.payload.implement(keywords, torrents) - - def _encode_votecast(self, message): - return pack('!20shl', message.payload.cid, message.payload.vote, message.payload.timestamp), - - def _decode_votecast(self, placeholder, offset, data): - if len(data) < offset + 26: - raise DropPacket("Unable to decode the payload") - - cid, vote, timestamp = unpack_from('!20shl', data, offset) - if not vote in [-1, 0, 2]: - raise DropPacket("Invalid 'vote' type or value") - - return offset + 26, placeholder.meta.payload.implement(cid, vote, timestamp) - - # def _encode_torrent_request(self, message): - # return message.payload.infohash, - - # def _decode_torrent_request(self, placeholder, offset, data): - # if len(data) < offset + 20: - # raise DropPacket("Insufficient packet size") - - # infohash = data[offset:offset+20] - # offset += 20 - - # return offset, placeholder.meta.payload.implement(infohash) diff --git a/Tribler/community/allchannel/message.py b/Tribler/community/allchannel/message.py deleted file mode 100644 index 0c006642d87..00000000000 --- a/Tribler/community/allchannel/message.py +++ /dev/null @@ -1,27 +0,0 @@ -from Tribler.community.channel.community import ChannelCommunity -from Tribler.dispersy.message import DelayMessage - - -class DelayMessageReqChannelMessage(DelayMessage): - """ - Raised during ChannelCommunity.check_ if the channel message has not been received yet. - """ - - def __init__(self, delayed, channel_community, includeSnapshot=False): - super(DelayMessageReqChannelMessage, self).__init__(delayed) - if __debug__: - from Tribler.dispersy.message import Message - assert isinstance(delayed, Message.Implementation), type(delayed) - assert isinstance(channel_community, ChannelCommunity), type(channel_community) - - self._channel_community = channel_community - self._includeSnapshot = includeSnapshot - - @property - def match_info(self): - # we return the channel_community cid here, to register the delay at that community - return (self._channel_community.cid, u"channel", None, None, []), - - def send_request(self, community, candidate): - # the request is sent from within the channel_community - self._channel_community.disp_create_missing_channel(candidate, self._includeSnapshot) diff --git a/Tribler/community/allchannel/payload.py b/Tribler/community/allchannel/payload.py deleted file mode 100644 index e202ee1e4b7..00000000000 --- a/Tribler/community/allchannel/payload.py +++ /dev/null @@ -1,116 +0,0 @@ -from Tribler.dispersy.payload import Payload - - -class ChannelCastPayload(Payload): - """ - Propagate semi random channel data. - - One channel-propagate message could contain a list with the following ChannelCommunity packets: - - torrent - """ - class Implementation(Payload.Implementation): - - def __init__(self, meta, torrents): - if __debug__: - assert isinstance(torrents, dict), 'torrents should be a dictionary containing cid:set(infohashes)' - for cid, infohashes in torrents.iteritems(): - assert isinstance(cid, str) - assert len(cid) == 20 - assert isinstance(infohashes, set) - assert not filter(lambda x: not isinstance(x, str), infohashes) - assert not filter(lambda x: not len(x) == 20, infohashes) - assert len(infohashes) > 0 - - super(ChannelCastPayload.Implementation, self).__init__(meta) - self._torrents = torrents - - @property - def torrents(self): - return self._torrents - - -class ChannelCastRequestPayload(ChannelCastPayload): - pass - - -class ChannelSearchPayload(Payload): - - """ - Propagate a search for a channel - """ - class Implementation(Payload.Implementation): - - def __init__(self, meta, keywords): - if __debug__: - assert isinstance(keywords, list), 'keywords should be list' - for keyword in keywords: - assert isinstance(keyword, unicode), '%s is type %s' % (keyword, type(keyword)) - assert len(keyword) > 0 - - super(ChannelSearchPayload.Implementation, self).__init__(meta) - self._keywords = keywords - - @property - def keywords(self): - return self._keywords - - -class ChannelSearchResponsePayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, keywords, torrents): - if __debug__: - assert isinstance(keywords, list), 'keywords should be list' - assert isinstance(torrents, dict), 'torrents should be a dictionary containing cid:set(infohashes)' - for cid, infohashes in torrents.iteritems(): - assert isinstance(cid, str) - assert len(cid) == 20 - assert isinstance(infohashes, set) - assert not filter(lambda x: not isinstance(x, str), infohashes) - assert not filter(lambda x: not len(x) == 20, infohashes) - assert len(infohashes) > 0 - - super(ChannelSearchResponsePayload.Implementation, self).__init__(meta) - self._keywords = keywords - self._torrents = torrents - - @property - def keywords(self): - return self._keywords - - @property - def torrents(self): - return self._torrents - - -class VoteCastPayload(Payload): - - """ - Propagate vote for a channel - """ - class Implementation(Payload.Implementation): - - def __init__(self, meta, cid, vote, timestamp): - assert isinstance(cid, str) - assert len(cid) == 20 - assert isinstance(vote, int) - assert vote in [-1, 0, 2] - assert isinstance(timestamp, (int, long)) - - super(VoteCastPayload.Implementation, self).__init__(meta) - self._cid = cid - self._vote = vote - self._timestamp = timestamp - - @property - def cid(self): - return self._cid - - @property - def vote(self): - return self._vote - - @property - def timestamp(self): - return self._timestamp diff --git a/Tribler/community/channel/__init__.py b/Tribler/community/channel/__init__.py deleted file mode 100644 index cc89965b023..00000000000 --- a/Tribler/community/channel/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Channels are lists "favorite" .torrents create by one or more users. -""" diff --git a/Tribler/community/channel/community.py b/Tribler/community/channel/community.py deleted file mode 100644 index 3db59a2f7cd..00000000000 --- a/Tribler/community/channel/community.py +++ /dev/null @@ -1,1286 +0,0 @@ -import logging -from binascii import hexlify -from struct import pack -from time import time -from traceback import print_stack -from twisted.python.threadable import isInIOThread - -from Tribler.Core.CacheDB.sqlitecachedb import str2bin -from Tribler.Core.simpledefs import NTFY_CHANNEL, NTFY_TORRENT -from Tribler.Core.simpledefs import NTFY_DISCOVERED -import Tribler.Core.Utilities.json_util as json -from Tribler.community.channel.payload import ModerationPayload -from Tribler.dispersy.authentication import MemberAuthentication, NoAuthentication -from Tribler.dispersy.candidate import CANDIDATE_WALK_LIFETIME -from Tribler.dispersy.community import Community -from Tribler.dispersy.conversion import DefaultConversion -from Tribler.dispersy.destination import CandidateDestination, CommunityDestination -from Tribler.dispersy.distribution import FullSyncDistribution, DirectDistribution -from Tribler.dispersy.exception import MetaNotFoundException -from Tribler.dispersy.message import BatchConfiguration, Message, DropMessage, DelayMessageByProof -from Tribler.dispersy.resolution import LinearResolution, PublicResolution, DynamicResolution -from Tribler.dispersy.util import call_on_reactor_thread -from .conversion import ChannelConversion -from .message import DelayMessageReqChannelMessage -from .payload import (ChannelPayload, TorrentPayload, PlaylistPayload, CommentPayload, ModificationPayload, - PlaylistTorrentPayload, MissingChannelPayload, MarkTorrentPayload) - -logger = logging.getLogger(__name__) - - -METADATA_TYPES = [u'name', u'description', u'swift-url', u'swift-thumbnails', u'video-info', u'metadata-json'] - - -def warnIfNotDispersyThread(func): - def invoke_func(*args, **kwargs): - if not isInIOThread(): - logger.critical("This method MUST be called on the DispersyThread") - print_stack() - return None - else: - return func(*args, **kwargs) - - invoke_func.__name__ = func.__name__ - return invoke_func - - -class ChannelCommunity(Community): - - """ - Each user owns zero or more ChannelCommunities that other can join and use to discuss. - """ - - def __init__(self, *args, **kwargs): - super(ChannelCommunity, self).__init__(*args, **kwargs) - - self._channel_id = None - self._channel_name = None - self._channel_description = None - - self.tribler_session = None - self.integrate_with_tribler = None - - self._peer_db = None - self._channelcast_db = None - - def initialize(self, tribler_session=None): - self.tribler_session = tribler_session - self.integrate_with_tribler = tribler_session is not None - - super(ChannelCommunity, self).initialize() - - if self.integrate_with_tribler: - from Tribler.Core.simpledefs import NTFY_PEERS, NTFY_CHANNELCAST - - # tribler channelcast database - self._peer_db = tribler_session.open_dbhandler(NTFY_PEERS) - self._channelcast_db = tribler_session.open_dbhandler(NTFY_CHANNELCAST) - - # tribler channel_id - result = self._channelcast_db._db.fetchone( - u"SELECT id, name, description FROM Channels WHERE dispersy_cid = ? and (peer_id <> -1 or peer_id ISNULL)", - (buffer(self._master_member.mid), - )) - if result is not None: - self._channel_id, self._channel_name, self._channel_description = result - - else: - try: - message = self._get_latest_channel_message() - if message: - self._channel_id = self.cid - except (MetaNotFoundException, RuntimeError): - pass - - from Tribler.community.allchannel.community import AllChannelCommunity - for community in self.dispersy.get_communities(): - if isinstance(community, AllChannelCommunity): - self._channelcast_db = community._channelcast_db - - def initiate_meta_messages(self): - batch_delay = 3.0 - - # 30/11/11 Boudewijn: we frequently see dropped packets when joining a channel. this can be - # caused when a sync results in both torrent and modification messages. when the - # modification messages are processed first they will all cause the associated torrent - # message to be requested, when these are received they are duplicates. solution: ensure - # that the modification messages are processed after messages that they can request. normal - # priority is 128, therefore, modification_priority is one less - modification_priority = 128 - 1 - - return super(ChannelCommunity, self).initiate_meta_messages() + [ - Message(self, u"channel", - MemberAuthentication(), - LinearResolution(), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=130), - CommunityDestination(node_count=10), - ChannelPayload(), - self._disp_check_channel, - self._disp_on_channel), - Message(self, u"torrent", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=129), - CommunityDestination(node_count=10), - TorrentPayload(), - self._disp_check_torrent, - self._disp_on_torrent, - self._disp_undo_torrent, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"playlist", - MemberAuthentication(), - LinearResolution(), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - PlaylistPayload(), - self._disp_check_playlist, - self._disp_on_playlist, - self._disp_undo_playlist, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"comment", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - CommentPayload(), - self._disp_check_comment, - self._disp_on_comment, - self._disp_undo_comment, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"modification", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, - synchronization_direction=u"DESC", - priority=modification_priority), - CommunityDestination(node_count=10), - ModificationPayload(), - self._disp_check_modification, - self._disp_on_modification, - self._disp_undo_modification, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"playlist_torrent", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - PlaylistTorrentPayload(), - self._disp_check_playlist_torrent, - self._disp_on_playlist_torrent, - self._disp_undo_playlist_torrent, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"moderation", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - ModerationPayload(), - self._disp_check_moderation, - self._disp_on_moderation, - self._disp_undo_moderation, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"mark_torrent", - MemberAuthentication(), - DynamicResolution(LinearResolution(), PublicResolution()), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"DESC", priority=128), - CommunityDestination(node_count=10), - MarkTorrentPayload(), - self._disp_check_mark_torrent, - self._disp_on_mark_torrent, - self._disp_undo_mark_torrent, - batch=BatchConfiguration(max_window=batch_delay)), - Message(self, u"missing-channel", - NoAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - MissingChannelPayload(), - self._disp_check_missing_channel, - self._disp_on_missing_channel), - ] - - @property - def dispersy_sync_response_limit(self): - return 25 * 1024 - - def initiate_conversions(self): - return [DefaultConversion(self), ChannelConversion(self)] - - CHANNEL_CLOSED, CHANNEL_SEMI_OPEN, CHANNEL_OPEN, CHANNEL_MODERATOR = range(4) - CHANNEL_ALLOWED_MESSAGES = ([], - [u"comment", u"mark_torrent"], - [u"torrent", - u"comment", - u"modification", - u"playlist_torrent", - u"moderation", - u"mark_torrent"], - [u"channel", - u"torrent", - u"playlist", - u"comment", - u"modification", - u"playlist_torrent", - u"moderation", - u"mark_torrent"]) - - def get_channel_id(self): - return self._channel_id - - def get_channel_name(self): - return self._channel_name - - def get_channel_description(self): - return self._channel_description - - def get_channel_mode(self): - public = set() - permitted = set() - - for meta in self.get_meta_messages(): - if isinstance(meta.resolution, DynamicResolution): - policy, _ = self._timeline.get_resolution_policy(meta, self.global_time + 1) - else: - policy = meta.resolution - - if isinstance(policy, PublicResolution): - public.add(meta.name) - else: - allowed, _ = self._timeline.allowed(meta) - if allowed: - permitted.add(meta.name) - - def isCommunityType(state, checkPermitted=False): - for type in ChannelCommunity.CHANNEL_ALLOWED_MESSAGES[state]: - if type not in public: - if checkPermitted and type in permitted: - continue - return False - return True - - isModerator = isCommunityType(ChannelCommunity.CHANNEL_MODERATOR, True) - if isCommunityType(ChannelCommunity.CHANNEL_OPEN): - return ChannelCommunity.CHANNEL_OPEN, isModerator - - if isCommunityType(ChannelCommunity.CHANNEL_SEMI_OPEN): - return ChannelCommunity.CHANNEL_SEMI_OPEN, isModerator - - return ChannelCommunity.CHANNEL_CLOSED, isModerator - - def set_channel_mode(self, mode): - curmode, isModerator = self.get_channel_mode() - if isModerator and mode != curmode: - public_messages = ChannelCommunity.CHANNEL_ALLOWED_MESSAGES[mode] - - new_policies = [] - for meta in self.get_meta_messages(): - if isinstance(meta.resolution, DynamicResolution): - if meta.name in public_messages: - new_policies.append((meta, meta.resolution.policies[1])) - else: - new_policies.append((meta, meta.resolution.policies[0])) - - self.create_dynamic_settings(new_policies) - - def create_channel(self, name, description, store=True, update=True, forward=True): - self._disp_create_channel(name, description, store, update, forward) - - @call_on_reactor_thread - def _disp_create_channel(self, name, description, store=True, update=True, forward=True): - name = unicode(name[:255]) - description = unicode(description[:1023]) - - meta = self.get_meta_message(u"channel") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.claim_global_time(),), - payload=(name, description)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_channel(self, messages): - for message in messages: - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - continue - - yield message - - def _disp_on_channel(self, messages): - if self.integrate_with_tribler: - for message in messages: - assert self._cid == self._master_member.mid - logger.debug("%s %s", message.candidate, self._cid.encode("HEX")) - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - self._channel_id = self._channelcast_db.on_channel_from_dispersy(self._master_member.mid, - peer_id, - message.payload.name, - message.payload.description) - - self.tribler_session.notifier.notify(NTFY_CHANNEL, NTFY_DISCOVERED, None, - {"name": message.payload.name, - "description": message.payload.description, - "dispersy_cid": self._cid.encode("hex")}) - - # emit signal of channel creation if the channel is created by us - if authentication_member == self._my_member: - self._channel_name = message.payload.name - self._channel_description = message.payload.description - - from Tribler.Core.simpledefs import SIGNAL_CHANNEL, SIGNAL_ON_CREATED - channel_data = {u'channel': self, - u'name': message.payload.name, - u'description': message.payload.description} - self.tribler_session.notifier.notify(SIGNAL_CHANNEL, SIGNAL_ON_CREATED, None, channel_data) - else: - for message in messages: - self._channel_id = self._master_member.mid - authentication_member = message.authentication.member - - self._channelcast_db.setChannelId(self._channel_id, authentication_member == self._my_member) - - def _disp_create_torrent_from_torrentdef(self, torrentdef, timestamp, store=True, update=True, forward=True): - files = torrentdef.get_files_with_length() - return (self._disp_create_torrent(torrentdef.get_infohash(), timestamp, - torrentdef.get_name_as_unicode(), tuple(files), - torrentdef.get_trackers_as_single_tuple(), store, update, forward)) - - def _disp_create_torrent(self, infohash, timestamp, name, files, trackers, store=True, update=True, forward=True): - meta = self.get_meta_message(u"torrent") - - global_time = self.claim_global_time() - current_policy, _ = self._timeline.get_resolution_policy(meta, global_time) - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(global_time,), - payload=(infohash, timestamp, name, files, trackers)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_create_torrents(self, torrentlist, store=True, update=True, forward=True): - messages = [] - - meta = self.get_meta_message(u"torrent") - current_policy, _ = self._timeline.get_resolution_policy(meta, self.global_time + 1) - for infohash, timestamp, name, files, trackers in torrentlist: - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(self.claim_global_time(),), - payload=(infohash, timestamp, name, files, trackers)) - - messages.append(message) - - self._dispersy.store_update_forward(messages, store, update, forward) - return messages - - def _disp_check_torrent(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - continue - yield message - - def _disp_on_torrent(self, messages): - if self.integrate_with_tribler: - torrentlist = [] - for message in messages: - dispersy_id = message.packet_id - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - # sha_other_peer = (sha1(str(message.candidate.sock_addr) + self.my_member.mid)) - torrentlist.append( - (self._channel_id, - dispersy_id, - peer_id, - message.payload.infohash, - message.payload.timestamp, - message.payload.name, - message.payload.files, - message.payload.trackers)) - self._logger.debug("torrent received: %s on channel: %s", hexlify(message.payload.infohash), self._master_member) - - self.tribler_session.notifier.notify(NTFY_TORRENT, NTFY_DISCOVERED, None, - {"infohash": hexlify(message.payload.infohash), - "timestamp": message.payload.timestamp, - "name": message.payload.name, - "files": message.payload.files, - "trackers": message.payload.trackers, - "dispersy_cid": self._cid.encode("hex")}) - - self._channelcast_db.on_torrents_from_dispersy(torrentlist) - else: - for message in messages: - self._channelcast_db.newTorrent(message) - self._logger.debug("torrent received: %s on channel: %s", message.payload.infohash, self._master_member) - - def _disp_undo_torrent(self, descriptors, redo=False): - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - self._channelcast_db.on_remove_torrent_from_dispersy(self._channel_id, dispersy_id, redo) - - def remove_torrents(self, dispersy_ids): - for dispersy_id in dispersy_ids: - message = self._dispersy.load_message_by_packetid(self, dispersy_id) - if message: - if not message.undone: - self.create_undo(message) - - else: # hmm signal gui that this message has been removed already - self._disp_undo_torrent([(None, None, message)]) - - def remove_playlists(self, dispersy_ids): - for dispersy_id in dispersy_ids: - message = self._dispersy.load_message_by_packetid(self, dispersy_id) - if message: - if not message.undone: - self.create_undo(message) - - else: # hmm signal gui that this message has been removed already - self._disp_undo_playlist([(None, None, message)]) - - # create, check or receive playlists - @call_on_reactor_thread - def create_playlist(self, name, description, infohashes=[], store=True, update=True, forward=True): - message = self._disp_create_playlist(name, description) - if len(infohashes) > 0: - self._disp_create_playlist_torrents(message, infohashes, store, update, forward) - - @call_on_reactor_thread - def _disp_create_playlist(self, name, description, store=True, update=True, forward=True): - name = unicode(name[:255]) - description = unicode(description[:1023]) - - meta = self.get_meta_message(u"playlist") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.claim_global_time(),), - payload=(name, description)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_playlist(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - continue - yield message - - def _disp_on_playlist(self, messages): - if self.integrate_with_tribler: - for message in messages: - dispersy_id = message.packet_id - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - self._channelcast_db.on_playlist_from_dispersy(self._channel_id, - dispersy_id, - peer_id, - message.payload.name, - message.payload.description) - - def _disp_undo_playlist(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - self._channelcast_db.on_remove_playlist_from_dispersy(self._channel_id, dispersy_id, redo) - - # create, check or receive comments - @call_on_reactor_thread - def create_comment(self, text, timestamp, reply_to, reply_after, playlist_id, infohash, store=True, update=True, - forward=True): - reply_to_message = reply_to - reply_after_message = reply_after - playlist_message = playlist_id - - if reply_to: - reply_to_message = self._dispersy.load_message_by_packetid(self, reply_to) - if reply_after: - reply_after_message = self._dispersy.load_message_by_packetid(self, reply_after) - if playlist_id: - playlist_message = self._get_message_from_playlist_id(playlist_id) - self._disp_create_comment(text, timestamp, reply_to_message, - reply_after_message, playlist_message, - infohash, store, update, forward) - - @call_on_reactor_thread - def _disp_create_comment(self, text, timestamp, reply_to_message, reply_after_message, playlist_message, infohash, - store=True, update=True, forward=True): - reply_to_mid = None - reply_to_global_time = None - if reply_to_message: - message = reply_to_message.load_message() - reply_to_mid = message.authentication.member.mid - reply_to_global_time = message.distribution.global_time - - reply_after_mid = None - reply_after_global_time = None - if reply_after_message: - message = reply_after_message.load_message() - reply_after_mid = message.authentication.member.mid - reply_after_global_time = message.distribution.global_time - - text = unicode(text[:1023]) - - meta = self.get_meta_message(u"comment") - global_time = self.claim_global_time() - current_policy, _ = self._timeline.get_resolution_policy(meta, global_time) - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(global_time,), payload=(text, - timestamp, reply_to_mid, reply_to_global_time, - reply_after_mid, reply_after_global_time, - playlist_message, infohash)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_comment(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - continue - yield message - - def _disp_on_comment(self, messages): - if self.integrate_with_tribler: - - for message in messages: - dispersy_id = message.packet_id - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - mid_global_time = pack('!20sQ', message.authentication.member.mid, message.distribution.global_time) - - reply_to_id = None - if message.payload.reply_to_mid: - try: - reply_to_id = self._get_packet_id( - message.payload.reply_to_global_time, - message.payload.reply_to_mid) - except: - reply_to_id = pack('!20sQ', message.payload.reply_to_mid, message.payload.reply_to_global_time) - - reply_after_id = None - if message.payload.reply_after_mid: - try: - reply_after_id = self._get_packet_id( - message.payload.reply_after_global_time, - message.payload.reply_after_mid) - except: - reply_after_id = pack( - '!20sQ', - message.payload.reply_after_mid, - message.payload.reply_after_global_time) - - playlist_dispersy_id = None - if message.payload.playlist_packet: - playlist_dispersy_id = message.payload.playlist_packet.packet_id - - self._channelcast_db.on_comment_from_dispersy(self._channel_id, - dispersy_id, - mid_global_time, - peer_id, - message.payload.text, - message.payload.timestamp, - reply_to_id, - reply_after_id, - playlist_dispersy_id, - message.payload.infohash) - - def _disp_undo_comment(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - - message = packet.load_message() - infohash = message.payload.infohash - self._channelcast_db.on_remove_comment_from_dispersy(self._channel_id, dispersy_id, infohash, redo) - - def remove_comment(self, dispersy_id): - message = self._dispersy.load_message_by_packetid(self, dispersy_id) - if message: - self.create_undo(message) - - # modify channel, playlist or torrent - @call_on_reactor_thread - def modifyChannel(self, modifications, store=True, update=True, forward=True): - latest_modifications = {} - for type, value in modifications.iteritems(): - type = unicode(type) - latest_modifications[type] = self._get_latest_modification_from_channel_id(type) - modification_on_message = self._get_latest_channel_message() - - for type, value in modifications.iteritems(): - type = unicode(type) - timestamp = long(time()) - self._disp_create_modification(type, value, timestamp, - modification_on_message, - latest_modifications[type], store, - update, forward) - - @call_on_reactor_thread - def modifyPlaylist(self, playlist_id, modifications, store=True, update=True, forward=True): - latest_modifications = {} - for type, value in modifications.iteritems(): - type = unicode(type) - latest_modifications[type] = self._get_latest_modification_from_playlist_id(playlist_id, type) - - modification_on_message = self._get_message_from_playlist_id(playlist_id) - for type, value in modifications.iteritems(): - type = unicode(type) - timestamp = long(time()) - self._disp_create_modification(type, value, timestamp, - modification_on_message, - latest_modifications[type], store, - update, forward) - - @call_on_reactor_thread - def modifyTorrent(self, channeltorrent_id, modifications, store=True, update=True, forward=True): - latest_modifications = {} - for type, value in modifications.iteritems(): - type = unicode(type) - try: - latest_modifications[type] = self._get_latest_modification_from_torrent_id(channeltorrent_id, type) - except: - logger.error(exc_info=True) - - modification_on_message = self._get_message_from_torrent_id(channeltorrent_id) - for type, value in modifications.iteritems(): - timestamp = long(time()) - self._disp_create_modification(type, value, timestamp, - modification_on_message, - latest_modifications[type], store, - update, forward) - - def _disp_create_modification(self, modification_type, modifcation_value, timestamp, modification_on, - latest_modification, store=True, update=True, forward=True): - modification_type = unicode(modification_type) - modifcation_value = unicode(modifcation_value[:1023]) - - latest_modification_mid = None - latest_modification_global_time = None - if latest_modification: - message = latest_modification.load_message() - latest_modification_mid = message.authentication.member.mid - latest_modification_global_time = message.distribution.global_time - - meta = self.get_meta_message(u"modification") - global_time = self.claim_global_time() - current_policy, _ = self._timeline.get_resolution_policy(meta, global_time) - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(global_time,), - payload=(modification_type, modifcation_value, - timestamp, modification_on, latest_modification, - latest_modification_mid, - latest_modification_global_time)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_modification(self, messages): - th_handler = self.tribler_session.lm.rtorrent_handler - - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - continue - - if message.payload.modification_on.name == u"torrent" and message.payload.modification_type == u"metadata-json": - try: - data = json.loads(message.payload.modification_value) - thumbnail_hash = data[u'thumb_hash'].decode('hex') - except ValueError: - yield DropMessage(message, "Not compatible json format") - continue - else: - modifying_dispersy_id = message.payload.modification_on.packet_id - torrent_id = self._channelcast_db._db.fetchone( - u"SELECT torrent_id FROM _ChannelTorrents WHERE dispersy_id = ?", - (modifying_dispersy_id,)) - infohash = self._channelcast_db._db.fetchone( - u"SELECT infohash FROM Torrent WHERE torrent_id = ?", (torrent_id,)) - if infohash: - infohash = str2bin(infohash) - logger.debug( - "Incoming metadata-json with infohash %s from %s", - infohash.encode("HEX"), - message.candidate.sock_addr[0]) - - if not th_handler.has_metadata(thumbnail_hash): - @call_on_reactor_thread - def callback(_, message=message): - self.on_messages([message]) - logger.debug( - "Will try to download metadata-json thumbnail with infohash %s from %s", - infohash.encode("HEX"), - message.candidate.sock_addr[0]) - th_handler.download_metadata(message.candidate, thumbnail_hash, usercallback=callback, - timeout=CANDIDATE_WALK_LIFETIME) - continue - - yield message - - def _disp_on_modification(self, messages): - if self.integrate_with_tribler: - channeltorrentDict = {} - playlistDict = {} - - for message in messages: - dispersy_id = message.packet_id - message_name = message.payload.modification_on.name - mid_global_time = "%s@%d" % (message.authentication.member.mid, message.distribution.global_time) - - modifying_dispersy_id = message.payload.modification_on.packet_id - modification_type = unicode(message.payload.modification_type) - modification_value = message.payload.modification_value - timestamp = message.payload.timestamp - - if message.payload.prev_modification_packet: - prev_modification_id = message.payload.prev_modification_packet.packet_id - else: - prev_modification_id = message.payload.prev_modification_id - prev_modification_global_time = message.payload.prev_modification_global_time - - # load local ids from database - if message_name == u"torrent": - channeltorrent_id = self._get_torrent_id_from_message(modifying_dispersy_id) - if not channeltorrent_id: - self._logger.info("CANNOT FIND channeltorrent_id %s", modifying_dispersy_id) - channeltorrentDict[modifying_dispersy_id] = channeltorrent_id - - elif message_name == u"playlist": - playlist_id = self._get_playlist_id_from_message(modifying_dispersy_id) - playlistDict[modifying_dispersy_id] = playlist_id - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - # always store metadata - self._channelcast_db.on_metadata_from_dispersy(message_name, - channeltorrentDict.get(modifying_dispersy_id, None), - playlistDict.get(modifying_dispersy_id, None), - self._channel_id, - dispersy_id, - peer_id, - mid_global_time, - modification_type, - modification_value, - timestamp, - prev_modification_id, - prev_modification_global_time) - - for message in messages: - dispersy_id = message.packet_id - message_name = message.payload.modification_on.name - - modifying_dispersy_id = message.payload.modification_on.packet_id - modification_type = unicode(message.payload.modification_type) - modification_value = message.payload.modification_value - - # see if this is new information, if so call on_X_from_dispersy to update local 'cached' information - if message_name == u"torrent": - channeltorrent_id = channeltorrentDict[modifying_dispersy_id] - - if channeltorrent_id: - latest = self._get_latest_modification_from_torrent_id(channeltorrent_id, modification_type) - if not latest or latest.packet_id == dispersy_id: - self._channelcast_db.on_torrent_modification_from_dispersy( - channeltorrent_id, modification_type, modification_value) - - elif message_name == u"playlist": - playlist_id = playlistDict[modifying_dispersy_id] - - latest = self._get_latest_modification_from_playlist_id(playlist_id, modification_type) - if not latest or latest.packet_id == dispersy_id: - self._channelcast_db.on_playlist_modification_from_dispersy( - playlist_id, modification_type, modification_value) - - elif message_name == u"channel": - latest = self._get_latest_modification_from_channel_id(modification_type) - if not latest or latest.packet_id == dispersy_id: - self._channelcast_db.on_channel_modification_from_dispersy( - self._channel_id, modification_type, modification_value) - - def _disp_undo_modification(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - - message = packet.load_message() - message_name = message.name - modifying_dispersy_id = message.payload.modification_on.packet_id - modification_type = unicode(message.payload.modification_type) - - # load local ids from database - playlist_id = channeltorrent_id = None - if message_name == u"torrent": - channeltorrent_id = self._get_torrent_id_from_message(modifying_dispersy_id) - - elif message_name == u"playlist": - playlist_id = self._get_playlist_id_from_message(modifying_dispersy_id) - self._channelcast_db.on_remove_metadata_from_dispersy(self._channel_id, dispersy_id, redo) - - if message_name == u"torrent": - latest = self._get_latest_modification_from_torrent_id(channeltorrent_id, modification_type) - - if not latest or latest.packet_id == dispersy_id: - modification_value = latest.payload.modification_value if latest else '' - self._channelcast_db.on_torrent_modification_from_dispersy( - channeltorrent_id, modification_type, modification_value) - - elif message_name == u"playlist": - latest = self._get_latest_modification_from_playlist_id(playlist_id, modification_type) - - if not latest or latest.packet_id == dispersy_id: - modification_value = latest.payload.modification_value if latest else '' - self._channelcast_db.on_playlist_modification_from_dispersy( - playlist_id, modification_type, modification_value) - - elif message_name == u"channel": - latest = self._get_latest_modification_from_channel_id(modification_type) - - if not latest or latest.packet_id == dispersy_id: - modification_value = latest.payload.modification_value if latest else '' - self._channelcast_db.on_channel_modification_from_dispersy( - self._channel_id, modification_type, modification_value) - - # create, check or receive playlist_torrent messages - @call_on_reactor_thread - def create_playlist_torrents(self, playlist_id, infohashes, store=True, update=True, forward=True): - playlist_packet = self._get_message_from_playlist_id(playlist_id) - self._disp_create_playlist_torrents(playlist_packet, infohashes, store, update, forward) - - def remove_playlist_torrents(self, playlist_id, dispersy_ids): - for dispersy_id in dispersy_ids: - message = self._dispersy.load_message_by_packetid(self, dispersy_id) - if message: - if not message.undone: - self.create_undo(message) - else: - self._disp_undo_playlist_torrent([(None, None, message)]) - - @call_on_reactor_thread - def _disp_create_playlist_torrents(self, playlist_packet, infohashes, store=True, update=True, forward=True): - meta = self.get_meta_message(u"playlist_torrent") - current_policy, _ = self._timeline.get_resolution_policy(meta, self.global_time + 1) - - messages = [] - for infohash in infohashes: - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(self.claim_global_time(),), - payload=(infohash, playlist_packet)) - messages.append(message) - - self._dispersy.store_update_forward(messages, store, update, forward) - return message - - def _disp_check_playlist_torrent(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - yield message - - def _disp_on_playlist_torrent(self, messages): - if self.integrate_with_tribler: - for message in messages: - dispersy_id = message.packet_id - playlist_dispersy_id = message.payload.playlist.packet_id - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - self._channelcast_db.on_playlist_torrent(dispersy_id, - playlist_dispersy_id, - peer_id, - message.payload.infohash) - - def _disp_undo_playlist_torrent(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - message = packet.load_message() - infohash = message.payload.infohash - playlist_dispersy_id = message.payload.playlist.packet_id - - self._channelcast_db.on_remove_playlist_torrent(self._channel_id, playlist_dispersy_id, infohash, redo) - - # check or receive moderation messages - @call_on_reactor_thread - def _disp_create_moderation(self, text, timestamp, severity, cause, store=True, update=True, forward=True): - causemessage = self._dispersy.load_message_by_packetid(self, cause) - if causemessage: - text = unicode(text[:1023]) - - meta = self.get_meta_message(u"moderation") - global_time = self.claim_global_time() - current_policy, _ = self._timeline.get_resolution_policy(meta, global_time) - - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(global_time,), - payload=(text, timestamp, severity, causemessage)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_moderation(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - - yield message - - def _disp_on_moderation(self, messages): - if self.integrate_with_tribler: - for message in messages: - dispersy_id = message.packet_id - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - # if cause packet is present, it is enforced by conversion - cause = message.payload.causepacket.packet_id - cause_message = message.payload.causepacket.load_message() - authentication_member = cause_message.authentication.member - if authentication_member == self._my_member: - by_peer_id = None - else: - by_peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - - # determine if we are reverting latest - updateTorrent = False - - modifying_dispersy_id = cause_message.payload.modification_on.packet_id - channeltorrent_id = self._get_torrent_id_from_message(modifying_dispersy_id) - if channeltorrent_id: - modification_type = unicode(cause_message.payload.modification_type) - - latest = self._get_latest_modification_from_torrent_id(channeltorrent_id, modification_type) - if not latest or latest.packet_id == cause_message.packet_id: - updateTorrent = True - - self._channelcast_db.on_moderation(self._channel_id, - dispersy_id, peer_id, - by_peer_id, cause, - message.payload.text, - message.payload.timestamp, - message.payload.severity) - - if updateTorrent: - latest = self._get_latest_modification_from_torrent_id(channeltorrent_id, modification_type) - - modification_value = latest.payload.modification_value if latest else '' - self._channelcast_db.on_torrent_modification_from_dispersy( - channeltorrent_id, modification_type, modification_value) - - def _disp_undo_moderation(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - self._channelcast_db.on_remove_moderation(self._channel_id, dispersy_id, redo) - - # check or receive torrent_mark messages - @call_on_reactor_thread - def _disp_create_mark_torrent(self, infohash, type, timestamp, store=True, update=True, forward=True): - meta = self.get_meta_message(u"mark_torrent") - global_time = self.claim_global_time() - current_policy, _ = self._timeline.get_resolution_policy(meta, global_time) - - message = meta.impl(authentication=(self._my_member,), - resolution=(current_policy.implement(),), - distribution=(global_time,), - payload=(infohash, type, timestamp)) - self._dispersy.store_update_forward([message], store, update, forward) - return message - - def _disp_check_mark_torrent(self, messages): - for message in messages: - if not self._channel_id: - yield DelayMessageReqChannelMessage(message) - continue - - accepted, proof = self._timeline.check(message) - if not accepted: - yield DelayMessageByProof(message) - yield message - - def _disp_on_mark_torrent(self, messages): - if self.integrate_with_tribler: - for message in messages: - dispersy_id = message.packet_id - global_time = message.distribution.global_time - - authentication_member = message.authentication.member - if authentication_member == self._my_member: - peer_id = None - else: - peer_id = self._peer_db.addOrGetPeerID(authentication_member.public_key) - self._channelcast_db.on_mark_torrent( - self._channel_id, - dispersy_id, - global_time, - peer_id, - message.payload.infohash, - message.payload.type, - message.payload.timestamp) - - def _disp_undo_mark_torrent(self, descriptors, redo=False): - if self.integrate_with_tribler: - for _, _, packet in descriptors: - dispersy_id = packet.packet_id - self._channelcast_db.on_remove_mark_torrent(self._channel_id, dispersy_id, redo) - - def disp_create_missing_channel(self, candidate, includeSnapshot): - logger.debug("%s sending missing-channel %s %s", candidate, self._cid.encode("HEX"), includeSnapshot) - meta = self._meta_messages[u"missing-channel"] - request = meta.impl(distribution=(self.global_time,), destination=(candidate,), payload=(includeSnapshot,)) - self._dispersy._forward([request]) - - # check or receive missing channel messages - def _disp_check_missing_channel(self, messages): - return messages - - def _disp_on_missing_channel(self, messages): - channelmessage = self._get_latest_channel_message() - packets = None - - for message in messages: - if message.payload.includeSnapshot: - if packets is None: - packets = [] - packets.append(channelmessage.packet) - - torrents = self._channelcast_db.getRandomTorrents(self._channel_id) - for infohash in torrents: - tormessage = self._get_message_from_torrent_infohash(infohash) - if tormessage: - packets.append(tormessage.packet) - - self._dispersy._send_packets([message.candidate], packets, - self, "-caused by missing-channel-response-snapshot-") - - else: - self._dispersy._send_packets([message.candidate], [channelmessage.packet], - self, "-caused by missing-channel-response-") - - def on_dynamic_settings(self, *args, **kwargs): - Community.on_dynamic_settings(self, *args, **kwargs) - if self._channel_id and self.integrate_with_tribler: - self._channelcast_db.on_dynamic_settings(self._channel_id) - - # helper functions - @warnIfNotDispersyThread - def _get_latest_channel_message(self): - channel_meta = self.get_meta_message(u"channel") - - # 1. get the packet - try: - packet, packet_id = self._dispersy.database.execute( - u"SELECT packet, id FROM sync WHERE meta_message = ? ORDER BY global_time DESC LIMIT 1", - (channel_meta.database_id,)).next() - except StopIteration: - raise RuntimeError("Could not find requested packet") - - message = self._dispersy.convert_packet_to_message(str(packet)) - if message: - assert message.name == u"channel", "Expecting a 'channel' message" - message.packet_id = packet_id - else: - raise RuntimeError("Unable to convert packet, could not find channel-message for channel %d" % - channel_meta.database_id) - - return message - - def _get_message_from_playlist_id(self, playlist_id): - assert isinstance(playlist_id, (int, long)) - - # 1. get the dispersy identifier from the channel_id - dispersy_id, _ = self._channelcast_db.getPlaylist(playlist_id, ('Playlists.dispersy_id',)) - - # 2. get the message - if dispersy_id and dispersy_id > 0: - return self._dispersy.load_message_by_packetid(self, dispersy_id) - - def _get_playlist_id_from_message(self, dispersy_id): - assert isinstance(dispersy_id, (int, long)) - return self._channelcast_db._db.fetchone(u"SELECT id FROM _Playlists WHERE dispersy_id = ?", (dispersy_id,)) - - def _get_message_from_torrent_id(self, torrent_id): - assert isinstance(torrent_id, (int, long)) - - # 1. get the dispersy identifier from the channel_id - dispersy_id = self._channelcast_db.getTorrentFromChannelTorrentId(torrent_id, ['ChannelTorrents.dispersy_id']) - - # 2. get the message - if dispersy_id and dispersy_id > 0: - return self._dispersy.load_message_by_packetid(self, dispersy_id) - - def _get_message_from_torrent_infohash(self, torrent_infohash): - assert isinstance(torrent_infohash, str), 'infohash is a %s' % type(torrent_infohash) - assert len(torrent_infohash) == 20, 'infohash has length %d' % len(torrent_infohash) - - # 1. get the dispersy identifier from the channel_id - dispersy_id = self._channelcast_db.getTorrentFromChannelId(self._channel_id, - torrent_infohash, - ['ChannelTorrents.dispersy_id']) - - if dispersy_id and dispersy_id > 0: - # 2. get the message - return self._dispersy.load_message_by_packetid(self, dispersy_id) - - def _get_torrent_id_from_message(self, dispersy_id): - assert isinstance(dispersy_id, (int, long)), "dispersy_id type is '%s'" % type(dispersy_id) - - return self._channelcast_db._db.fetchone(u"SELECT id FROM _ChannelTorrents WHERE dispersy_id = ?", (dispersy_id,)) - - def _get_latest_modification_from_channel_id(self, type_name): - assert isinstance(type_name, basestring), "type_name is not a basestring: %s" % repr(type_name) - - # 1. get the dispersy identifier from the channel_id - dispersy_ids = self._channelcast_db._db.fetchall( - u"SELECT dispersy_id, prev_global_time " + \ - u"FROM ChannelMetaData WHERE type = ? " + \ - u"AND channel_id = ? " + \ - u"AND id NOT IN (SELECT metadata_id FROM MetaDataTorrent) " + \ - u"AND id NOT IN (SELECT metadata_id FROM MetaDataPlaylist) " + \ - u"AND dispersy_id not in (SELECT cause FROM Moderations " + \ - u"WHERE channel_id = ?) ORDER BY prev_global_time DESC", - (type_name, self._channel_id, self._channel_id)) - return self._determine_latest_modification(dispersy_ids) - - def _get_latest_modification_from_torrent_id(self, channeltorrent_id, type_name): - assert isinstance(channeltorrent_id, (int, long)), "channeltorrent_id type is '%s'" % type(channeltorrent_id) - assert isinstance(type_name, basestring), "type_name is not a basestring: %s" % repr(type_name) - - # 1. get the dispersy identifier from the channel_id - dispersy_ids = self._channelcast_db._db.fetchall(u"SELECT dispersy_id, prev_global_time " + \ - u"FROM ChannelMetaData, MetaDataTorrent " + \ - u"WHERE ChannelMetaData.id = MetaDataTorrent.metadata_id " + \ - u"AND type = ? AND channeltorrent_id = ? " + \ - u"AND dispersy_id not in " + \ - u"(SELECT cause FROM Moderations WHERE channel_id = ?) " + \ - u"ORDER BY prev_global_time DESC", - (type_name, channeltorrent_id, self._channel_id)) - return self._determine_latest_modification(dispersy_ids) - - def _get_latest_modification_from_playlist_id(self, playlist_id, type_name): - assert isinstance(playlist_id, (int, long)), "playlist_id type is '%s'" % type(playlist_id) - assert isinstance(type_name, basestring), "type_name is not a basestring: %s" % repr(type_name) - - # 1. get the dispersy identifier from the channel_id - dispersy_ids = self._channelcast_db._db.fetchall(u"SELECT dispersy_id, prev_global_time " + \ - u"FROM ChannelMetaData, MetaDataPlaylist " + \ - u"WHERE ChannelMetaData.id = MetaDataPlaylist.metadata_id " + \ - u"AND type = ? AND playlist_id = ? " + \ - u"AND dispersy_id not in " + \ - u"(SELECT cause FROM Moderations WHERE channel_id = ?) " + \ - u"ORDER BY prev_global_time DESC", - (type_name, playlist_id, self._channel_id)) - return self._determine_latest_modification(dispersy_ids) - - @warnIfNotDispersyThread - def _determine_latest_modification(self, list): - - if len(list) > 0: - # 1. determine if we have a conflict - max_global_time = list[0][1] - conflicting_messages = [] - for dispersy_id, prev_global_time in list: - if prev_global_time >= max_global_time: - try: - message = self._dispersy.load_message_by_packetid(self, dispersy_id) - if message: - message = message.load_message() - conflicting_messages.append(message) - - max_global_time = prev_global_time - except RuntimeError: - pass - else: - break - - # 2. see if we have a conflict - if len(conflicting_messages) > 1: - - # 3. solve conflict using mid to sort on - def cleverSort(message_a, message_b): - public_key_a = message_a.authentication.member.public_key - public_key_b = message_a.authentication.member.public_key - - if public_key_a == public_key_b: - return cmp(message_b.distribution.global_time, message_a.distribution.global_time) - - return cmp(public_key_a, public_key_b) - - conflicting_messages.sort(cleverSort) - - if len(conflicting_messages) > 0: - # 4. return first message - return conflicting_messages[0] - - @warnIfNotDispersyThread - def _get_packet_id(self, global_time, mid): - if global_time and mid: - try: - packet_id, = self._dispersy.database.execute(u""" - SELECT sync.id - FROM sync - JOIN member ON (member.id = sync.member) - JOIN meta_message ON (meta_message.id = sync.meta_message) - WHERE sync.community = ? AND sync.global_time = ? AND member.mid = ?""", - (self.database_id, global_time, buffer(mid))).next() - except StopIteration: - pass - return packet_id diff --git a/Tribler/community/channel/conversion.py b/Tribler/community/channel/conversion.py deleted file mode 100644 index 7ddd6674f00..00000000000 --- a/Tribler/community/channel/conversion.py +++ /dev/null @@ -1,448 +0,0 @@ -import zlib -from random import sample -from struct import pack, unpack_from - -from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url -from Tribler.dispersy.conversion import BinaryConversion -from Tribler.dispersy.message import DropPacket, Packet, DelayPacketByMissingMessage, DelayPacketByMissingMember -from Tribler.pyipv8.ipv8.messaging.deprecated.encoding import encode, decode - -DEBUG = False - - -class ChannelConversion(BinaryConversion): - - def __init__(self, community): - super(ChannelConversion, self).__init__(community, "\x01") - self.define_meta_message(chr(1), community.get_meta_message(u"channel"), - self._encode_channel, - self._decode_channel) - self.define_meta_message(chr(2), community.get_meta_message(u"torrent"), - self._encode_torrent, - self._decode_torrent) - self.define_meta_message(chr(3), community.get_meta_message(u"playlist"), - self._encode_playlist, - self._decode_playlist) - self.define_meta_message(chr(4), community.get_meta_message(u"comment"), - self._encode_comment, - self._decode_comment) - self.define_meta_message(chr(5), - community.get_meta_message(u"modification"), - self._encode_modification, - self._decode_modification) - self.define_meta_message(chr(6), - community.get_meta_message(u"playlist_torrent"), - self._encode_playlist_torrent, - self._decode_playlist_torrent) - self.define_meta_message(chr(7), - community.get_meta_message(u"missing-channel"), - self._encode_missing_channel, - self._decode_missing_channel) - self.define_meta_message(chr(8), - community.get_meta_message(u"moderation"), - self._encode_moderation, - self._decode_moderation) - self.define_meta_message(chr(9), community.get_meta_message(u"mark_torrent"), - self._encode_mark_torrent, - self._decode_mark_torrent) - - def _encode_channel(self, message): - return encode((message.payload.name, message.payload.description)), - - def _decode_channel(self, placeholder, offset, data): - try: - offset, values = decode(data, offset) - if len(values) != 2: - raise ValueError - except ValueError: - raise DropPacket("Unable to decode the channel-payload") - - name = values[0] - if not (isinstance(name, unicode) and len(name) < 256): - raise DropPacket("Invalid 'name' type or value") - - description = values[1] - if not (isinstance(description, unicode) and len(description) < 1024): - raise DropPacket("Invalid 'description' type or value") - - return offset, placeholder.meta.payload.implement(name, description) - - def _encode_playlist(self, message): - return self._encode_channel(message) - - def _decode_playlist(self, placeholder, offset, data): - return self._decode_channel(placeholder, offset, data) - - def _encode_torrent(self, message): - files = message.payload.files - trackers = list(message.payload.trackers) - name = message.payload.name - - # Filter out invalid trackers - for tracker in trackers: - if not get_uniformed_tracker_url(tracker) or len(tracker) > 200: - trackers.remove(tracker) - - # files is a tuple of tuples (actually a list in tuple form) - max_len = self._community.dispersy_sync_bloom_filter_bits / 8 - base_len = 20 + 8 + len(name) # infohash, timestamp, name - tracker_len = sum([len(tracker) for tracker in trackers]) - file_len = sum([len(f[0]) + 8 for f in files]) # file name, length - # Check if the message fits in the bloomfilter - if (base_len + tracker_len + file_len > max_len) and (len(trackers) > 10): - # only use first 10 trackers, .torrents in the wild have been seen to have 1000+ trackers... - trackers = trackers[:10] - tracker_len = sum([len(tracker) for tracker in trackers]) - if base_len + tracker_len + file_len > max_len: - # reduce files by the amount we are currently to big - reduce_by = max_len / (base_len + tracker_len + file_len * 1.0) - nr_files_to_include = int(len(files) * reduce_by) - files = sample(files, nr_files_to_include) - - normal_msg = (pack('!20sQ', message.payload.infohash, message.payload.timestamp), message.payload.name, - tuple(files), tuple(trackers)) - - return zlib.compress(encode(normal_msg)), - - def _decode_torrent(self, placeholder, offset, data): - try: - uncompressed_data = zlib.decompress(data[offset:]) - except zlib.error: - raise DropPacket("Invalid zlib data") - offset = len(data) - - try: - _, values = decode(uncompressed_data) - except ValueError: - raise DropPacket("Unable to decode the torrent-payload") - - infohash_time, name, files, trackers = values - if len(infohash_time) != 28: - raise DropPacket("Unable to decode the torrent-payload, got %d bytes expected 28" % (len(infohash_time))) - infohash, timestamp = unpack_from('!20sQ', infohash_time) - - if not isinstance(name, unicode): - raise DropPacket("Invalid 'name' type") - - if not isinstance(files, tuple): - raise DropPacket("Invalid 'files' type") - - if len(files) == 0: - raise DropPacket("Should have at least one file") - - for file in files: - if len(file) != 2: - raise DropPacket("Invalid 'file_len' type") - - path, length = file - if not isinstance(path, unicode): - raise DropPacket("Invalid 'files_path' type is %s" % type(path)) - if not isinstance(length, (int, long)): - raise DropPacket("Invalid 'files_length' type is %s" % type(length)) - - if not isinstance(trackers, tuple): - raise DropPacket("Invalid 'trackers' type") - for tracker in trackers: - if not isinstance(tracker, str): - raise DropPacket("Invalid 'tracker' type") - - return offset, placeholder.meta.payload.implement(infohash, timestamp, name, files, trackers) - - def _encode_comment(self, message): - dict = {"text": message.payload.text, - "timestamp": message.payload.timestamp} - - playlist_packet = message.payload.playlist_packet - infohash = message.payload.infohash - - if message.payload.reply_to_mid: - dict["reply-to-mid"] = message.payload.reply_to_mid - dict["reply-to-global-time"] = message.payload.reply_to_global_time - - if message.payload.reply_after_mid: - dict["reply-after-mid"] = message.payload.reply_after_mid - dict["reply-after-global-time"] = message.payload.reply_after_global_time - - if playlist_packet: - message = playlist_packet.load_message() - dict["playlist-mid"] = message.authentication.member.mid - dict["playlist-global-time"] = message.distribution.global_time - - if infohash: - dict['infohash'] = infohash - return encode(dict), - - def _decode_comment(self, placeholder, offset, data): - try: - offset, dic = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the payload") - - if not "text" in dic: - raise DropPacket("Missing 'text'") - text = dic["text"] - if not (isinstance(text, unicode) and len(text) < 1024): - raise DropPacket("Invalid 'text' type or value") - - if not "timestamp" in dic: - raise DropPacket("Missing 'timestamp'") - timestamp = dic["timestamp"] - if not isinstance(timestamp, (int, long)): - raise DropPacket("Invalid 'timestamp' type or value") - - reply_to_mid = dic.get("reply-to-mid", None) - if reply_to_mid and not (isinstance(reply_to_mid, str) and len(reply_to_mid) == 20): - raise DropPacket("Invalid 'reply-to-mid' type or value") - - reply_to_global_time = dic.get("reply-to-global-time", None) - if reply_to_global_time and not isinstance(reply_to_global_time, (int, long)): - raise DropPacket("Invalid 'reply-to-global-time' type") - - reply_after_mid = dic.get("reply-after-mid", None) - if reply_after_mid and not (isinstance(reply_after_mid, str) and len(reply_after_mid) == 20): - raise DropPacket("Invalid 'reply-after-mid' type or value") - - reply_after_global_time = dic.get("reply-after-global-time", None) - if reply_after_global_time and not isinstance(reply_after_global_time, (int, long)): - raise DropPacket("Invalid 'reply-after-global-time' type") - - playlist_mid = dic.get("playlist-mid", None) - if playlist_mid and not (isinstance(playlist_mid, str) and len(playlist_mid) == 20): - raise DropPacket("Invalid 'playlist-mid' type or value") - - playlist_global_time = dic.get("playlist-global-time", None) - if playlist_global_time and not isinstance(playlist_global_time, (int, long)): - raise DropPacket("Invalid 'playlist-global-time' type") - - if playlist_mid and playlist_global_time: - try: - packet_id, packet, message_name = self._get_message(playlist_global_time, playlist_mid) - playlist = Packet(self._community.get_meta_message(message_name), packet, packet_id) - except DropPacket: - member = self._community.get_member(mid=playlist_mid) - if not member: - raise DelayPacketByMissingMember(self._community, playlist_mid) - raise DelayPacketByMissingMessage(self._community, member, playlist_global_time) - else: - playlist = None - - infohash = dic.get("infohash", None) - if infohash and not (isinstance(infohash, str) and len(infohash) == 20): - raise DropPacket("Invalid 'infohash' type or value") - return offset, placeholder.meta.payload.implement(text, timestamp, reply_to_mid, reply_to_global_time, reply_after_mid, reply_after_global_time, playlist, infohash) - - def _encode_moderation(self, message): - dict = {"text": message.payload.text, - "timestamp": message.payload.timestamp, - "severity": message.payload.severity} - - dict["cause-mid"] = message.payload.cause_mid - dict["cause-global-time"] = message.payload.cause_global_time - return encode(dict), - - def _decode_moderation(self, placeholder, offset, data): - try: - offset, dic = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the payload") - - if not "text" in dic: - raise DropPacket("Missing 'text'") - text = dic["text"] - if not (isinstance(text, unicode) and len(text) < 1024): - raise DropPacket("Invalid 'text' type or value") - - if not "timestamp" in dic: - raise DropPacket("Missing 'timestamp'") - timestamp = dic["timestamp"] - if not isinstance(timestamp, (int, long)): - raise DropPacket("Invalid 'timestamp' type or value") - - if not "severity" in dic: - raise DropPacket("Missing 'severity'") - severity = dic["severity"] - if not isinstance(severity, (int, long)): - raise DropPacket("Invalid 'severity' type or value") - - cause_mid = dic.get("cause-mid", None) - if not (isinstance(cause_mid, str) and len(cause_mid) == 20): - raise DropPacket("Invalid 'cause-mid' type or value") - - cause_global_time = dic.get("cause-global-time", None) - if not isinstance(cause_global_time, (int, long)): - raise DropPacket("Invalid 'cause-global-time' type") - - try: - packet_id, packet, message_name = self._get_message(cause_global_time, cause_mid) - cause_packet = Packet(self._community.get_meta_message(message_name), packet, packet_id) - - except DropPacket: - member = self._community.get_member(mid=cause_mid) - if not member: - raise DelayPacketByMissingMember(self._community, cause_mid) - raise DelayPacketByMissingMessage(self._community, member, cause_global_time) - - return offset, placeholder.meta.payload.implement(text, timestamp, severity, cause_packet) - - def _encode_mark_torrent(self, message): - dict = {"infohash": message.payload.infohash, - "timestamp": message.payload.timestamp, - "type": message.payload.type} - - return encode(dict), - - def _decode_mark_torrent(self, placeholder, offset, data): - try: - offset, dic = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the payload") - - if not "infohash" in dic: - raise DropPacket("Missing 'infohash'") - infohash = dic["infohash"] - if not (isinstance(infohash, str) and len(infohash) == 20): - raise DropPacket("Invalid 'infohash' type or value") - - if not "timestamp" in dic: - raise DropPacket("Missing 'timestamp'") - timestamp = dic["timestamp"] - if not isinstance(timestamp, (int, long)): - raise DropPacket("Invalid 'timestamp' type or value") - - if not "type" in dic: - raise DropPacket("Missing 'type'") - type = dic["type"] - if not (isinstance(type, unicode) and len(type) < 25): - raise DropPacket("Invalid 'type' type or value") - - return offset, placeholder.meta.payload.implement(infohash, type, timestamp) - - def _encode_modification(self, message): - modification_on = message.payload.modification_on.load_message() - dict = {"modification-type": message.payload.modification_type, - "modification-value": message.payload.modification_value, - "timestamp": message.payload.timestamp, - "modification-on-mid": modification_on.authentication.member.mid, - "modification-on-global-time": modification_on.distribution.global_time} - - prev_modification = message.payload.prev_modification_packet - if prev_modification: - message = prev_modification.load_message() - dict["prev-modification-mid"] = message.authentication.member.mid - dict["prev-modification-global-time"] = message.distribution.global_time - - return encode(dict), - - def _decode_modification(self, placeholder, offset, data): - try: - offset, dic = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the payload") - - if not "modification-type" in dic: - raise DropPacket("Missing 'modification-type'") - modification_type = dic["modification-type"] - if not isinstance(modification_type, unicode): - raise DropPacket("Invalid 'modification_type' type") - - if not "modification-value" in dic: - raise DropPacket("Missing 'modification-value'") - modification_value = dic["modification-value"] - if not (isinstance(modification_value, unicode) and len(modification_value) < 1024): - raise DropPacket("Invalid 'modification_value' type or value") - - if not "timestamp" in dic: - raise DropPacket("Missing 'timestamp'") - timestamp = dic["timestamp"] - if not isinstance(timestamp, (int, long)): - raise DropPacket("Invalid 'timestamp' type or value") - - if not "modification-on-mid" in dic: - raise DropPacket("Missing 'modification-on-mid'") - modification_on_mid = dic["modification-on-mid"] - if not (isinstance(modification_on_mid, str) and len(modification_on_mid) == 20): - raise DropPacket("Invalid 'modification-on-mid' type or value") - - if not "modification-on-global-time" in dic: - raise DropPacket("Missing 'modification-on-global-time'") - modification_on_global_time = dic["modification-on-global-time"] - if not isinstance(modification_on_global_time, (int, long)): - raise DropPacket("Invalid 'modification-on-global-time' type") - - try: - packet_id, packet, message_name = self._get_message(modification_on_global_time, modification_on_mid) - modification_on = Packet(self._community.get_meta_message(message_name), packet, packet_id) - except DropPacket: - member = self._community.get_member(mid=modification_on_mid) - if not member: - raise DelayPacketByMissingMember(self._community, modification_on_mid) - raise DelayPacketByMissingMessage(self._community, member, modification_on_global_time) - - prev_modification_mid = dic.get("prev-modification-mid", None) - if prev_modification_mid and not (isinstance(prev_modification_mid, str) and len(prev_modification_mid) == 20): - raise DropPacket("Invalid 'prev-modification-mid' type or value") - - prev_modification_global_time = dic.get("prev-modification-global-time", None) - if prev_modification_global_time and not isinstance(prev_modification_global_time, (int, long)): - raise DropPacket("Invalid 'prev-modification-global-time' type") - - try: - packet_id, packet, message_name = self._get_message(prev_modification_global_time, prev_modification_mid) - prev_modification_packet = Packet(self._community.get_meta_message(message_name), packet, packet_id) - except: - prev_modification_packet = None - - return offset, placeholder.meta.payload.implement(modification_type, modification_value, timestamp, modification_on, prev_modification_packet, prev_modification_mid, prev_modification_global_time) - - def _encode_playlist_torrent(self, message): - playlist = message.payload.playlist.load_message() - return pack('!20s20sQ', message.payload.infohash, playlist.authentication.member.mid, playlist.distribution.global_time), - - def _decode_playlist_torrent(self, placeholder, offset, data): - if len(data) < offset + 48: - raise DropPacket("Unable to decode the payload") - - infohash, playlist_mid, playlist_global_time = unpack_from('!20s20sQ', data, offset) - try: - packet_id, packet, message_name = self._get_message(playlist_global_time, playlist_mid) - - except DropPacket: - member = self._community.dispersy.get_member(mid=playlist_mid) - if not member: - raise DelayPacketByMissingMember(self._community, playlist_mid) - raise DelayPacketByMissingMessage(self._community, member, playlist_global_time) - - playlist = Packet(self._community.get_meta_message(message_name), packet, packet_id) - return offset + 48, placeholder.meta.payload.implement(infohash, playlist) - - def _get_message(self, global_time, mid): - assert isinstance(global_time, (int, long)) - assert isinstance(mid, str) - assert len(mid) == 20 - if global_time and mid: - try: - packet_id, packet, message_name = self._community.dispersy.database.execute( - u""" SELECT sync.id, sync.packet, meta_message.name - FROM sync - JOIN member ON (member.id = sync.member) - JOIN meta_message ON (meta_message.id = sync.meta_message) - WHERE sync.community = ? AND sync.global_time = ? AND member.mid = ?""", - (self._community.database_id, global_time, buffer(mid))).next() - except StopIteration: - raise DropPacket("Missing message") - - return packet_id, str(packet), message_name - - def _encode_missing_channel(self, message): - return pack('!B', int(message.payload.includeSnapshot)), - - def _decode_missing_channel(self, placeholder, offset, data): - if len(data) < offset + 1: - raise DropPacket("Unable to decode the payload") - - includeSnapshot, = unpack_from('!B', data, offset) - if not (includeSnapshot == 0 or includeSnapshot == 1): - raise DropPacket("Unable to decode includeSnapshot") - includeSnapshot = bool(includeSnapshot) - - return offset + 1, placeholder.meta.payload.implement(includeSnapshot) diff --git a/Tribler/community/channel/message.py b/Tribler/community/channel/message.py deleted file mode 100644 index f3dded93d45..00000000000 --- a/Tribler/community/channel/message.py +++ /dev/null @@ -1,21 +0,0 @@ -from Tribler.dispersy.message import DelayMessage - - -class DelayMessageReqChannelMessage(DelayMessage): - """ - Raised during ChannelCommunity.check_ if the channel message has not been received yet. - """ - - def __init__(self, delayed, includeSnapshot=False): - super(DelayMessageReqChannelMessage, self).__init__(delayed) - if __debug__: - from Tribler.dispersy.message import Message - assert isinstance(delayed, Message.Implementation) - self._includeSnapshot = includeSnapshot - - @property - def match_info(self): - return (self._cid, u"channel", None, None, []), - - def send_request(self, community, candidate): - self._community.disp_create_missing_channel(candidate, self._includeSnapshot) diff --git a/Tribler/community/channel/payload.py b/Tribler/community/channel/payload.py deleted file mode 100644 index 01ea412eb18..00000000000 --- a/Tribler/community/channel/payload.py +++ /dev/null @@ -1,325 +0,0 @@ -from Tribler.dispersy.message import Packet -from Tribler.dispersy.payload import Payload - - -class ChannelPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, name, description): - assert isinstance(name, unicode) - assert len(name) < 256 - assert isinstance(description, unicode) - assert len(description) < 1024 - super(ChannelPayload.Implementation, self).__init__(meta) - self._name = name - self._description = description - - @property - def name(self): - return self._name - - @property - def description(self): - return self._description - - -class PlaylistPayload(ChannelPayload): - pass - - -class TorrentPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, infohash, timestamp, name, files, trackers): - assert isinstance(infohash, str), 'infohash is a %s' % type(infohash) - assert len(infohash) == 20, 'infohash has length %d' % len(infohash) - assert isinstance(timestamp, (int, long)) - - assert isinstance(name, unicode) - assert isinstance(files, tuple) - for path, length in files: - assert isinstance(path, unicode) - assert isinstance(length, (int, long)) - - assert isinstance(trackers, tuple) - for tracker in trackers: - assert isinstance(tracker, str), 'tracker is a %s' % type(tracker) - - super(TorrentPayload.Implementation, self).__init__(meta) - self._infohash = infohash - self._timestamp = timestamp - self._name = name - self._files = files - self._trackers = trackers - - @property - def infohash(self): - return self._infohash - - @property - def timestamp(self): - return self._timestamp - - @property - def name(self): - return self._name - - @property - def files(self): - return self._files - - @property - def trackers(self): - return self._trackers - - -class CommentPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, text, timestamp, reply_to_mid, reply_to_global_time, reply_after_mid, - reply_after_global_time, playlist_packet, infohash): - assert isinstance(text, unicode) - assert len(text) < 1024 - assert isinstance(timestamp, (int, long)) - - assert not reply_to_mid or isinstance(reply_to_mid, str), 'reply_to_mid is a %s' % type(reply_to_mid) - assert not reply_to_mid or len(reply_to_mid) == 20, 'reply_to_mid has length %d' % len(reply_to_mid) - assert not reply_to_global_time or isinstance(reply_to_global_time, ( - int, long)), 'reply_to_global_time is a %s' % type(reply_to_global_time) - - assert not reply_after_mid or isinstance( - reply_after_mid, str), 'reply_after_mid is a %s' % type(reply_after_mid) - assert not reply_after_mid or len( - reply_after_mid) == 20, 'reply_after_mid has length %d' % len(reply_after_global_time) - assert not reply_after_global_time or isinstance(reply_after_global_time, ( - int, long)), 'reply_after_global_time is a %s' % type(reply_to_global_time) - - assert not playlist_packet or isinstance(playlist_packet, Packet) - - assert not infohash or isinstance(infohash, str), 'infohash is a %s' % type(infohash) - assert not infohash or len(infohash) == 20, 'infohash has length %d' % len(infohash) - - super(CommentPayload.Implementation, self).__init__(meta) - self._text = text - self._timestamp = timestamp - self._reply_to_mid = reply_to_mid - self._reply_to_global_time = reply_to_global_time - - self._reply_after_mid = reply_after_mid - self._reply_after_global_time = reply_after_global_time - - self._playlist_packet = playlist_packet - self._infohash = infohash - - @property - def text(self): - return self._text - - @property - def timestamp(self): - return self._timestamp - - @property - def reply_to_mid(self): - return self._reply_to_mid - - @property - def reply_to_global_time(self): - return self._reply_to_global_time - - @property - def reply_after_mid(self): - return self._reply_after_mid - - @property - def reply_after_global_time(self): - return self._reply_after_global_time - - @property - def playlist_packet(self): - return self._playlist_packet - - @property - def infohash(self): - return self._infohash - - -class ModerationPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, text, timestamp, severity, causepacket): - - assert isinstance(causepacket, Packet) - - assert isinstance(text, unicode) - assert len(text) < 1024 - assert isinstance(timestamp, (int, long)) - assert isinstance(severity, (int, long)) - - super(ModerationPayload.Implementation, self).__init__(meta) - self._text = text - self._timestamp = timestamp - self._severity = severity - self._causepacket = causepacket - - message = causepacket.load_message() - self._mid = message.authentication.member.mid - self._global_time = message.distribution.global_time - - @property - def text(self): - return self._text - - @property - def timestamp(self): - return self._timestamp - - @property - def severity(self): - return self._severity - - @property - def causepacket(self): - return self._causepacket - - @property - def cause_mid(self): - return self._mid - - @property - def cause_global_time(self): - return self._global_time - - -class MarkTorrentPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, infohash, type_str, timestamp): - assert isinstance(infohash, str), 'infohash is a %s' % type(infohash) - assert len(infohash) == 20, 'infohash has length %d' % len(infohash) - - assert isinstance(type_str, unicode) - assert len(type_str) < 25 - assert isinstance(timestamp, (int, long)) - - super(MarkTorrentPayload.Implementation, self).__init__(meta) - self._infohash = infohash - self._type = type_str - self._timestamp = timestamp - - @property - def infohash(self): - return self._infohash - - @property - def type(self): - return self._type - - @property - def timestamp(self): - return self._timestamp - - -class ModificationPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, modification_type, modification_value, timestamp, modification_on, prev_modification_packet, prev_modification_mid, prev_modification_global_time): - assert isinstance(modification_type, unicode) - assert modification_value is not None - assert isinstance(modification_value, unicode) - assert len(modification_value) < 1024 - assert isinstance(modification_on, Packet) - - assert not prev_modification_packet or isinstance(prev_modification_packet, Packet) - assert not prev_modification_mid or isinstance( - prev_modification_mid, str), 'prev_modification_mid is a %s' % type(prev_modification_mid) - assert not prev_modification_mid or len( - prev_modification_mid) == 20, 'prev_modification_mid has length %d' % len(prev_modification_mid) - assert not prev_modification_global_time or isinstance(prev_modification_global_time, ( - int, long)), 'prev_modification_global_time is a %s' % type(prev_modification_global_time) - - super(ModificationPayload.Implementation, self).__init__(meta) - self._modification_type = modification_type - self._modification_value = modification_value - self._timestamp = timestamp - - self._modification_on = modification_on - - self._prev_modification_packet = prev_modification_packet - self._prev_modification_mid = prev_modification_mid - self._prev_modification_global_time = prev_modification_global_time - - @property - def modification_type(self): - return self._modification_type - - @property - def modification_value(self): - return self._modification_value - - @property - def timestamp(self): - return self._timestamp - - @property - def modification_on(self): - return self._modification_on - - @property - def prev_modification_packet(self): - return self._prev_modification_packet - - @property - def prev_modification_id(self): - if self._prev_modification_mid and self._prev_modification_global_time: - return "%s@%d" % (self._prev_modification_mid, self._prev_modification_global_time) - - @property - def prev_modification_mid(self): - return self._prev_modification_mid - - @property - def prev_modification_global_time(self): - return self._prev_modification_global_time - - -class PlaylistTorrentPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, infohash, playlist): - assert isinstance(infohash, str), 'infohash is a %s' % type(infohash) - assert len(infohash) == 20, 'infohash has length %d' % len(infohash) - assert isinstance(playlist, Packet), type(playlist) - super(PlaylistTorrentPayload.Implementation, self).__init__(meta) - self._infohash = infohash - self._playlist = playlist - - @property - def infohash(self): - return self._infohash - - @property - def playlist(self): - return self._playlist - - -class MissingChannelPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, includeSnapshot=False): - assert isinstance(includeSnapshot, bool), 'includeSnapshot is a %s' % type(includeSnapshot) - super(MissingChannelPayload.Implementation, self).__init__(meta) - - self._includeSnapshot = includeSnapshot - - @property - def includeSnapshot(self): - return self._includeSnapshot diff --git a/Tribler/community/channel/preview.py b/Tribler/community/channel/preview.py deleted file mode 100644 index 6f9bbbad6d8..00000000000 --- a/Tribler/community/channel/preview.py +++ /dev/null @@ -1,25 +0,0 @@ -from time import time - -from Tribler.community.channel.community import ChannelCommunity - - -class PreviewChannelCommunity(ChannelCommunity): - """ - The PreviewChannelCommunity extends the ChannelCommunity to allow ChannelCommunity messages to - be decoded while not actually joining or participating in an actual ChannelCommunity. - """ - - def __init__(self, *args, **kargs): - super(PreviewChannelCommunity, self).__init__(*args, **kargs) - self.init_timestamp = time() - - @property - def dispersy_enable_bloom_filter_sync(self): - return False - - @property - def dispersy_enable_candidate_walker(self): - return False - - def get_channel_mode(self): - return ChannelCommunity.CHANNEL_CLOSED, False diff --git a/Tribler/community/popularity/community.py b/Tribler/community/popularity/community.py index 5cdf4434984..be849b9783b 100644 --- a/Tribler/community/popularity/community.py +++ b/Tribler/community/popularity/community.py @@ -1,19 +1,13 @@ from __future__ import absolute_import +from pony.orm import db_session from twisted.internet.defer import inlineCallbacks -from Tribler.Core.simpledefs import SIGNAL_SEARCH_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS -from Tribler.community.popularity.constants import MSG_TORRENT_HEALTH_RESPONSE, MSG_CHANNEL_HEALTH_RESPONSE, \ - MSG_TORRENT_INFO_REQUEST, MSG_TORRENT_INFO_RESPONSE, \ - ERROR_UNKNOWN_RESPONSE, MAX_PACKET_PAYLOAD_SIZE, ERROR_UNKNOWN_PEER, ERROR_NO_CONTENT, \ - MSG_CONTENT_INFO_REQUEST, \ - SEARCH_TORRENT_REQUEST, MSG_CONTENT_INFO_RESPONSE, SEARCH_TORRENT_RESPONSE -from Tribler.community.popularity.payload import TorrentHealthPayload, ContentSubscription, TorrentInfoRequestPayload, \ - TorrentInfoResponsePayload, SearchResponseItemPayload, \ - ContentInfoRequest, Pagination, ContentInfoResponse, decode_values +from Tribler.community.popularity.constants import MSG_TORRENT_HEALTH_RESPONSE, \ + ERROR_UNKNOWN_RESPONSE, ERROR_UNKNOWN_PEER +from Tribler.community.popularity.payload import TorrentHealthPayload, ContentSubscription from Tribler.community.popularity.pubsub import PubSubCommunity -from Tribler.community.popularity.repository import ContentRepository, TYPE_TORRENT_HEALTH -from Tribler.community.popularity.request import ContentRequest +from Tribler.community.popularity.repository import ContentRepository from Tribler.pyipv8.ipv8.peer import Peer @@ -29,22 +23,15 @@ class PopularityCommunity(PubSubCommunity): master_peer = Peer(MASTER_PUBLIC_KEY.decode('hex')) def __init__(self, *args, **kwargs): - self.torrent_db = kwargs.pop('torrent_db', None) - self.channel_db = kwargs.pop('channel_db', None) - self.trustchain = kwargs.pop('trustchain_community', None) + self.metadata_store = kwargs.pop('metadata_store', None) self.tribler_session = kwargs.pop('session', None) super(PopularityCommunity, self).__init__(*args, **kwargs) - self.content_repository = ContentRepository(self.torrent_db, self.channel_db) + self.content_repository = ContentRepository(self.metadata_store) self.decode_map.update({ - chr(MSG_TORRENT_HEALTH_RESPONSE): self.on_torrent_health_response, - chr(MSG_CHANNEL_HEALTH_RESPONSE): self.on_channel_health_response, - chr(MSG_TORRENT_INFO_REQUEST): self.on_torrent_info_request, - chr(MSG_TORRENT_INFO_RESPONSE): self.on_torrent_info_response, - chr(MSG_CONTENT_INFO_REQUEST): self.on_content_info_request, - chr(MSG_CONTENT_INFO_RESPONSE): self.on_content_info_response + chr(MSG_TORRENT_HEALTH_RESPONSE): self.on_torrent_health_response }) self.logger.info('Popular Community initialized (peer mid %s)', self.my_peer.mid.encode('HEX')) @@ -81,85 +68,12 @@ def on_torrent_health_response(self, source_address, data): infohash = payload.infohash if not self.content_repository.has_torrent(infohash): - self.send_torrent_info_request(infohash, peer=peer) + # TODO(Martijn): we should probably try to fetch the torrent info from the other peer + return peer_trust = self.trustchain.get_trust(peer) if self.trustchain else 0 self.content_repository.update_torrent_health(payload, peer_trust) - def on_channel_health_response(self, source_address, data): - """ - Message handler for channel health response. Currently, not sure how to handle it. - """ - - def on_torrent_info_request(self, source_address, data): - """ - Message handler for torrent info request. - """ - self.logger.debug("Got torrent info request from %s", source_address) - auth, _, payload = self._ez_unpack_auth(TorrentInfoRequestPayload, data) - peer = self.get_peer_from_auth(auth, source_address) - - if peer not in self.subscribers: - self.logger.error(ERROR_UNKNOWN_RESPONSE) - return - - self.send_torrent_info_response(payload.infohash, peer=peer) - - def on_torrent_info_response(self, source_address, data): - """ - Message handler for torrent info response. - """ - self.logger.debug("Got torrent info response from %s", source_address) - auth, _, payload = self._ez_unpack_auth(TorrentInfoResponsePayload, data) - peer = self.get_peer_from_auth(auth, source_address) - - if peer not in self.publishers: - self.logger.error(ERROR_UNKNOWN_RESPONSE) - return - - self.content_repository.update_torrent_info(payload) - - def on_content_info_request(self, source_address, data): - auth, _, payload = self._ez_unpack_auth(ContentInfoRequest, data) - peer = self.get_peer_from_auth(auth, source_address) - - if payload.content_type == SEARCH_TORRENT_REQUEST: - db_results = self.content_repository.search_torrent(payload.query_list) - self.send_content_info_response(peer, payload.identifier, SEARCH_TORRENT_RESPONSE, db_results) - - def on_content_info_response(self, source_address, data): - _, _, payload = self._ez_unpack_auth(ContentInfoResponse, data) - - identifier = int(payload.identifier) - if not self.request_cache.has(u'request', identifier): - return - cache = self.request_cache.get(u'request', identifier) - - if payload.content_type == SEARCH_TORRENT_RESPONSE: - self.process_torrent_search_response(cache.query, payload) - - if not payload.pagination.more: - cache = self.request_cache.pop(u'request', identifier) - cache.finish() - - def process_torrent_search_response(self, query, payload): - item_format = SearchResponseItemPayload.format_list - response, _ = self.serializer.unpack_multiple_as_list(item_format, payload.response) - # Decode the category string to list - for response_item in response: - response_item[4] = decode_values(response_item[4]) - - self.content_repository.update_from_torrent_search_results(response) - - result_dict = dict() - result_dict['keywords'] = query - result_dict['results'] = response - result_dict['candidate'] = None - - if self.tribler_session: - self.tribler_session.notifier.notify(SIGNAL_SEARCH_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS, None, - result_dict) - # MESSAGE SENDING FUNCTIONS def send_torrent_health_response(self, payload, peer=None): @@ -174,128 +88,26 @@ def send_torrent_health_response(self, payload, peer=None): packet = self.create_message_packet(MSG_TORRENT_HEALTH_RESPONSE, payload) self.broadcast_message(packet, peer=peer) - def send_channel_health_response(self, payload, peer=None): - """ - Method to send channel health response. This message is sent to all the subscribers by default but if a - peer is specified then only that peer receives this message. - """ - if peer and peer not in self.get_peers(): - self.logger.debug(ERROR_UNKNOWN_PEER) - return - - packet = self.create_message_packet(MSG_CHANNEL_HEALTH_RESPONSE, payload) - self.broadcast_message(packet, peer=peer) - - def send_torrent_info_request(self, infohash, peer): - """ - Method to request information about a torrent with given infohash to a peer. - """ - if peer not in self.get_peers(): - self.logger.debug(ERROR_UNKNOWN_PEER) - return - - info_request = TorrentInfoRequestPayload(infohash) - packet = self.create_message_packet(MSG_TORRENT_INFO_REQUEST, info_request) - self.broadcast_message(packet, peer=peer) - - def send_torrent_info_response(self, infohash, peer): - """ - Method to send information about a torrent with given infohash to the requesting peer. - """ - if peer not in self.get_peers(): - self.logger.debug(ERROR_UNKNOWN_PEER) - return - - db_torrent = self.content_repository.get_torrent(infohash) - info_response = TorrentInfoResponsePayload(infohash, db_torrent['name'], db_torrent['length'], - db_torrent['creation_date'], db_torrent['num_files'], - db_torrent['comment']) - packet = self.create_message_packet(MSG_TORRENT_INFO_RESPONSE, info_response) - self.broadcast_message(packet, peer=peer) - - def send_content_info_request(self, content_type, request_list, limit=25, peer=None): - """ - Sends the generic content request of given content_type. - :param content_type: request content type - :param request_list: List request queries - :param limit: Number of expected responses - :param peer: Peer to send this request to - :return a Deferred that fires when we receive the content - :rtype Deferred - """ - cache = self.request_cache.add(ContentRequest(self.request_cache, content_type, request_list)) - self.logger.debug("Sending search request query:%s, identifier:%s", request_list, cache.number) - - content_request = ContentInfoRequest(cache.number, content_type, request_list, limit) - packet = self.create_message_packet(MSG_CONTENT_INFO_REQUEST, content_request) - - if peer: - self.broadcast_message(packet, peer=peer) - else: - for connected_peer in self.get_peers(): - self.broadcast_message(packet, peer=connected_peer) - - return cache.deferred - - def send_content_info_response(self, peer, identifier, content_type, response_list): - """ - Sends the generic content info response with payload response list. - :param peer: Receiving peer - :param identifier: Request identifier - :param content_type: Message content type - :param response_list: Content response - """ - num_results = len(response_list) - current_index = 0 - page_num = 1 - while current_index < num_results: - serialized_results, current_index, page_size = self.pack_sized(response_list, MAX_PACKET_PAYLOAD_SIZE, - start_index=current_index) - if not serialized_results: - self.logger.info("Item too big probably to fit into package. Skipping it") - current_index += 1 - else: - pagination = Pagination(page_num, page_size, num_results, more=current_index == num_results) - response_payload = ContentInfoResponse(identifier, content_type, serialized_results, pagination) - packet = self.create_message_packet(MSG_CONTENT_INFO_RESPONSE, response_payload) - self.broadcast_message(packet, peer=peer) - - def send_torrent_search_request(self, query): - """ - Sends torrent search query as a content info request with content_type as SEARCH_TORRENT_REQUEST. - """ - self.send_content_info_request(SEARCH_TORRENT_REQUEST, query) - - def send_channel_search_request(self, query): - """ - Sends channel search query to All Channel 2.0 to get a list of channels. - """ - # TODO: Not implemented yet. Waiting for All Channel 2.0 - # CONTENT REPOSITORY STUFFS def publish_next_content(self): """ Publishes the next content from the queue to the subscribers. - Does nothing if there are none subscribers. + Does nothing if there are no subscribers. Only Torrent health response is published at the moment. """ - self.logger.info("Content to publish: %d", self.content_repository.count_content()) + self.logger.info("Content to publish: %d", self.content_repository.queue_length()) if not self.subscribers: self.logger.info("No subscribers found. Not publishing anything") return - content_type, content = self.content_repository.pop_content() - if content_type is None: - self.logger.debug(ERROR_NO_CONTENT) - return - - self.logger.info("Publishing content[type:%d]", content_type) - if content_type == TYPE_TORRENT_HEALTH: + content = self.content_repository.pop_content() + if content: infohash, seeders, leechers, timestamp = content payload = TorrentHealthPayload(infohash, seeders, leechers, timestamp) self.send_torrent_health_response(payload) + @db_session def publish_latest_torrents(self, peer): """ Publishes the latest torrents in local database to the given peer. @@ -303,12 +115,12 @@ def publish_latest_torrents(self, peer): torrents = self.content_repository.get_top_torrents() self.logger.info("Publishing %d torrents to peer %s", len(torrents), peer) for torrent in torrents: - infohash, seeders, leechers, timestamp = torrent[:4] - payload = TorrentHealthPayload(infohash, seeders, leechers, timestamp) + payload = TorrentHealthPayload(str(torrent.infohash), torrent.health.seeders, torrent.health.leechers, + torrent.health.last_check) self.send_torrent_health_response(payload, peer=peer) - def queue_content(self, content_type, content): + def queue_content(self, content): """ Basically adds a given content to the queue of content repository. """ - self.content_repository.add_content(content_type, content) + self.content_repository.add_content_to_queue(content) diff --git a/Tribler/community/popularity/constants.py b/Tribler/community/popularity/constants.py index 70542090734..cff151fd64f 100644 --- a/Tribler/community/popularity/constants.py +++ b/Tribler/community/popularity/constants.py @@ -2,31 +2,11 @@ MSG_SUBSCRIBE = 1 MSG_SUBSCRIPTION = 2 MSG_TORRENT_HEALTH_RESPONSE = 3 -MSG_CHANNEL_HEALTH_RESPONSE = 4 -MSG_TORRENT_INFO_REQUEST = 5 -MSG_TORRENT_INFO_RESPONSE = 6 -MSG_SEARCH_REQUEST = 7 -MSG_SEARCH_RESPONSE = 8 -MSG_CONTENT_INFO_REQUEST = 9 -MSG_CONTENT_INFO_RESPONSE = 10 MAX_SUBSCRIBERS = 10 MAX_PUBLISHERS = 10 PUBLISH_INTERVAL = 5 -TORRENT_SEARCH_RESPONSE_TYPE = 0 -CHANNEL_SEARCH_RESPONSE_TYPE = 1 - -# Search types -TYPE_TORRENT = 0 -TYPE_CHANNEL = 1 - -# Request types -SEARCH_TORRENT_REQUEST = 11 -SEARCH_TORRENT_RESPONSE = 12 -SEARCH_CHANNEL_REQUEST = 13 -SEARCH_CHANNEL_RESPONSE = 14 - # Maximum packet payload size in bytes MAX_PACKET_PAYLOAD_SIZE = 500 diff --git a/Tribler/community/popularity/payload.py b/Tribler/community/popularity/payload.py index 9246e5e505f..2026e79ccfd 100644 --- a/Tribler/community/popularity/payload.py +++ b/Tribler/community/popularity/payload.py @@ -1,7 +1,6 @@ from __future__ import absolute_import -import struct -from struct import pack, unpack_from, calcsize +from struct import calcsize, pack, unpack_from from Tribler.pyipv8.ipv8.messaging.payload import Payload @@ -65,245 +64,3 @@ def to_pack_list(self): def from_unpack_list(cls, *args): (infohash, num_seeders, num_leechers, timestamp) = args return TorrentHealthPayload(infohash, num_seeders, num_leechers, timestamp) - - -class ChannelHealthPayload(Payload): - """ - Payload for a channel popularity message in the popularity community. - """ - - format_list = ['varlenI', 'I', 'I', 'I', 'Q'] - - def __init__(self, channel_id, num_votes, num_torrents, swarm_size_sum, timestamp): - super(ChannelHealthPayload, self).__init__() - self.channel_id = channel_id - self.num_votes = num_votes or 0 - self.num_torrents = num_torrents or 0 - self.swarm_size_sum = swarm_size_sum or 0 - self.timestamp = timestamp or 0 - - def to_pack_list(self): - data = [('varlenI', self.channel_id), - ('I', self.num_votes), - ('I', self.num_torrents), - ('I', self.swarm_size_sum), - ('Q', self.timestamp)] - - return data - - @classmethod - def from_unpack_list(cls, *args): - (channel_id, num_votes, num_torrents, swarm_size_sum, timestamp) = args - return ChannelHealthPayload(channel_id, num_votes, num_torrents, swarm_size_sum, timestamp) - - -class TorrentInfoRequestPayload(Payload): - """ - Payload for requesting torrent info for a given infohash. - """ - format_list = ['20s'] - - def __init__(self, infohash): - super(TorrentInfoRequestPayload, self).__init__() - self.infohash = infohash - - def to_pack_list(self): - data = [('20s', str(self.infohash))] - return data - - @classmethod - def from_unpack_list(cls, *args): - (infohash, ) = args - return TorrentInfoRequestPayload(infohash) - - -class TorrentInfoResponsePayload(Payload): - """ - Payload for torrent info response. - """ - format_list = ['20s', 'varlenH', 'Q', 'Q', 'I', 'varlenH'] - - def __init__(self, infohash, name, length, creation_date, num_files, comment): - super(TorrentInfoResponsePayload, self).__init__() - self.infohash = infohash - self.name = name or '' - self.length = length or 0 - self.creation_date = creation_date or 0 - self.num_files = num_files or 0 - self.comment = comment or '' - - def to_pack_list(self): - data = [('20s', self.infohash), - ('varlenH', self.name.encode('utf-8')), - ('Q', self.length), - ('Q', self.creation_date), - ('I', self.num_files), - ('varlenH', str(self.comment))] - return data - - @classmethod - def from_unpack_list(cls, *args): - (infohash, name, length, creation_date, num_files, comment) = args - return TorrentInfoResponsePayload(infohash, name.decode('utf-8'), length, creation_date, num_files, comment) - - -class SearchResponseItemPayload(Payload): - """ - Payload for search response items - """ - - format_list = ['20s', 'varlenH', 'Q', 'I', 'varlenH', 'Q', 'I', 'I', '20s'] - is_list_descriptor = True - - def __init__(self, infohash, name, length, num_files, category_list, creation_date, seeders, leechers, cid): - self.infohash = infohash - self.name = name - self.length = length or 0 - self.num_files = num_files or 0 - self.category_list = category_list or [] - self.creation_date = creation_date or 0 - self.seeders = seeders or 0 - self.leechers = leechers or 0 - self.cid = cid - - def to_pack_list(self): - data = [('20s', str(self.infohash)), - ('varlenH', self.name.encode('utf-8')), - ('Q', self.length), - ('I', self.num_files), - ('varlenH', encode_values(self.category_list)), - ('Q', self.creation_date), - ('I', self.seeders), - ('I', self.leechers), - ('20s', self.cid if self.cid else '')] - return data - - @classmethod - def from_unpack_list(cls, *args): - (infohash, name, length, num_files, category_list_str, creation_date, seeders, leechers, cid) = args - category_list = decode_values(category_list_str) - return SearchResponseItemPayload(infohash, name.decode('utf-8'), length, num_files, category_list, - creation_date, seeders, leechers, cid) - - -class ChannelItemPayload(Payload): - """ - Payload for search response channel items - """ - format_list = ['I', '20s', 'varlenH', 'varlenH', 'I', 'I', 'I', 'Q'] - is_list_descriptor = True - - def __init__(self, dbid, dispersy_cid, name, description, nr_torrents, nr_favorite, nr_spam, modified): - self.id = dbid - self.name = name - self.description = description or '' - self.cid = dispersy_cid - self.modified = modified or 0 - self.nr_torrents = nr_torrents or 0 - self.nr_favorite = nr_favorite or 0 - self.nr_spam = nr_spam or 0 - - def to_pack_list(self): - data = [('I', id), - ('20s', str(self.cid)), - ('varlenH', self.name), - ('varlenH', self.description.encode('utf-8')), - ('I', self.nr_torrents), - ('I', self.nr_favorite), - ('I', self.nr_spam), - ('Q', self.modified)] - return data - - @classmethod - def from_unpack_list(cls, dbid, dispersy_cid, name, description, nr_torrents, nr_favorite, nr_spam, modified): - return ChannelItemPayload(dbid, dispersy_cid, name.decode('utf-8'), description.decode('utf-8'), nr_torrents, - nr_favorite, nr_spam, modified) - - -class SearchResponsePayload(Payload): - """ - Payload for search response - """ - format_list = ['varlenI', 'I', 'varlenH'] - - def __init__(self, identifier, response_type, results): - self.identifier = identifier - self.response_type = response_type - self.results = results - - def to_pack_list(self): - data = [('varlenI', str(self.identifier)), - ('I', self.response_type), - ('varlenH', self.results)] - return data - - @classmethod - def from_unpack_list(cls, *args): - (identifier, response_type, results) = args - return SearchResponsePayload(identifier, response_type, results) - - -class Pagination(object): - - def __init__(self, page_number, page_size, max_results, more): - self.page_number = page_number - self.page_size = page_size - self.max_results = max_results - self.more = more - - def serialize(self): - return struct.pack('!HHH?', self.page_number, self.page_size, self.max_results, self.more) - - @classmethod - def deserialize(cls, pagination_str): - return Pagination(*struct.unpack('!HHH?', pagination_str)) - - -class ContentInfoRequest(Payload): - """ Payload for generic content request """ - - format_list = ['I', 'I', 'varlenH', 'I'] - - def __init__(self, identifier, content_type, query_list, limit): - self.identifier = identifier - self.content_type = content_type - self.query_list = query_list - self.limit = limit - - def to_pack_list(self): - data = [('I', self.identifier), - ('I', self.content_type), - ('varlenH', encode_values(self.query_list)), - ('I', self.limit)] - return data - - @classmethod - def from_unpack_list(cls, *args): - (identifier, content_type, query_list_str, limit) = args - query_list = decode_values(query_list_str) - return ContentInfoRequest(identifier, content_type, query_list, limit) - - -class ContentInfoResponse(Payload): - """ Payload for generic content response """ - - format_list = ['I', 'I', 'varlenH', 'varlenH'] - - def __init__(self, identifier, content_type, response, pagination): - self.identifier = identifier - self.content_type = content_type - self.response = response - self.pagination = pagination - - def to_pack_list(self): - data = [('I', self.identifier), - ('I', self.content_type), - ('varlenH', self.response), - ('varlenH', self.pagination.serialize())] - return data - - @classmethod - def from_unpack_list(cls, *args): - (identifier, content_type, response, pagination_str) = args - pagination = Pagination.deserialize(pagination_str) - return ContentInfoResponse(identifier, content_type, response, pagination) diff --git a/Tribler/community/popularity/pubsub.py b/Tribler/community/popularity/pubsub.py index 313b3a843f8..3ae27546efd 100644 --- a/Tribler/community/popularity/pubsub.py +++ b/Tribler/community/popularity/pubsub.py @@ -1,7 +1,7 @@ from __future__ import absolute_import import logging -from abc import abstractmethod +from binascii import unhexlify from copy import copy from twisted.internet.defer import inlineCallbacks from twisted.internet.task import LoopingCall @@ -25,9 +25,16 @@ class PubSubCommunity(Community): All the derived community should implement publish_next_content() method which is responsible for publishing the next available content to all the subscribers. """ + MASTER_PUBLIC_KEY = "3081a7301006072a8648ce3d020106052b8104002703819200040504278d20d6776ce7081ad57d99fe066bb2a93" \ + "ce7cc92405a534ef7175bab702be557d8c7d3b725ea0eb09c686e798f6c7ad85e8781a4c3b20e54c15ede38077c" \ + "8f5c801b71d13105f261da7ddcaa94ae14bd177bf1a05a66f595b9bb99117d11f73b4c8d3dcdcdc2b3f838b8ba3" \ + "5a9f600d2c543e8b3ba646083307b917bbbccfc53fc5ab6ded90b711d7eeda46f5f" + + master_peer = Peer(unhexlify(MASTER_PUBLIC_KEY)) def __init__(self, *args, **kwargs): super(PubSubCommunity, self).__init__(*args, **kwargs) + self.trustchain = kwargs.pop('trustchain_community', None) self.logger = logging.getLogger(self.__class__.__name__) self.request_cache = RequestCache() @@ -233,7 +240,6 @@ def pack_sized(self, payload_list, fit_size, start_index=0): current_index += 1 return serialized_results, current_index, current_index - start_index - @abstractmethod def publish_next_content(self): """ Method responsible for publishing content during periodic push """ pass diff --git a/Tribler/community/popularity/repository.py b/Tribler/community/popularity/repository.py index d71168a5aca..65bc2fcb544 100644 --- a/Tribler/community/popularity/repository.py +++ b/Tribler/community/popularity/repository.py @@ -4,9 +4,10 @@ import time from collections import deque -import six +from pony.orm import db_session, desc -from Tribler.community.popularity.payload import SearchResponseItemPayload, ChannelItemPayload +from Tribler.Core.Modules.MetadataStore.serialization import REGULAR_TORRENT +from Tribler.pyipv8.ipv8.database import database_blob try: long # pylint: disable=long-builtin @@ -19,7 +20,6 @@ DEFAULT_FRESHNESS_LIMIT = 60 TYPE_TORRENT_HEALTH = 1 -TYPE_CHANNEL_HEALTH = 2 class ContentRepository(object): @@ -30,161 +30,65 @@ class ContentRepository(object): It also maintains a content queue which stores the content for publishing in the next publishing cycle. """ - def __init__(self, torrent_db, channel_db): + def __init__(self, metadata_store): super(ContentRepository, self).__init__() self.logger = logging.getLogger(self.__class__.__name__) - self.torrent_db = torrent_db - self.channel_db = channel_db + self.metadata_store = metadata_store self.queue = deque(maxlen=MAX_CACHE) def cleanup(self): - self.torrent_db = None self.queue = None - def add_content(self, content_type, content): + def add_content_to_queue(self, content): if self.queue is not None: - self.queue.append((content_type, content)) + self.queue.append(content) - def count_content(self): + def queue_length(self): return len(self.queue) if self.queue else 0 def pop_content(self): - return self.queue.pop() if self.queue else (None, None) + return self.queue.pop() if self.queue else None + @db_session def get_top_torrents(self, limit=DEFAULT_TORRENT_LIMIT): - return self.torrent_db.getRecentlyCheckedTorrents(limit) + return list(self.metadata_store.TorrentMetadata.select( + lambda g: g.metadata_type == REGULAR_TORRENT).sort_by(desc("g.health.seeders")).limit(limit)) + @db_session def update_torrent_health(self, torrent_health_payload, peer_trust=0): - - def update_torrent(db_handler, health_payload): - db_handler.updateTorrent(infohash, notify=False, num_seeders=health_payload.num_seeders, - num_leechers=health_payload.num_leechers, - last_tracker_check=int(health_payload.timestamp), - status=u"good" if health_payload.num_seeders > 1 else u"unknown") - - if not self.torrent_db: - self.logger.error("Torrent DB is not available. Skipping torrent health update.") + """ + Update the health of a torrent in the database. + """ + if not self.metadata_store: + self.logger.error("Metadata store is not available. Skipping torrent health update.") return infohash = torrent_health_payload.infohash - if self.has_torrent(infohash): - db_torrent = self.get_torrent(infohash) - is_fresh = time.time() - db_torrent['last_tracker_check'] < DEFAULT_FRESHNESS_LIMIT - if is_fresh and peer_trust < 2: - self.logger.info("Database record is already fresh and the sending peer trust " - "score is too low so we just ignore the response.") - return - - # Update the torrent health anyway. A torrent info request should be sent separately to request additional info. - update_torrent(self.torrent_db, torrent_health_payload) - - def update_torrent_info(self, torrent_info_response): - infohash = torrent_info_response.infohash - if self.has_torrent(infohash): - db_torrent = self.get_torrent(infohash) - if db_torrent['name'] and db_torrent['name'] == torrent_info_response.name: - self.logger.info("Conflicting names for torrent. Ignoring the response") - return - - # Update local database - self.torrent_db.updateTorrent(infohash, notify=False, name=torrent_info_response.name, - length=torrent_info_response.length, - creation_date=torrent_info_response.creation_date, - num_files=torrent_info_response.num_files, - comment=torrent_info_response.comment) + if not self.has_torrent(infohash): + return + torrent = self.get_torrent(infohash) + is_fresh = time.time() - torrent.health.last_check < DEFAULT_FRESHNESS_LIMIT + if is_fresh and peer_trust < 2: + self.logger.info("Database record is already fresh and the sending peer trust " + "score is too low so we just ignore the response.") + else: + # Update the torrent health anyway. A torrent info request should be sent separately + # to request additional info. + torrent.health.seeders = torrent_health_payload.num_seeders + torrent.health.leechers = torrent_health_payload.num_leechers + torrent.health.last_check = int(torrent_health_payload.timestamp) + + @db_session def get_torrent(self, infohash): - keys = ('name', 'length', 'creation_date', 'num_files', 'num_seeders', 'num_leechers', 'comment', - 'last_tracker_check') - return self.torrent_db.getTorrent(infohash, keys=keys, include_mypref=False) - - def has_torrent(self, infohash): - return self.get_torrent(infohash) is not None - - def search_torrent(self, query): """ - Searches for best torrents for the given query and packs them into a list of SearchResponseItemPayload. - :param query: Search query - :return: List + Return a torrent with a specific infohash from the database. """ + results = list(self.metadata_store.TorrentMetadata.select( + lambda g: g.infohash == database_blob(infohash) and g.metadata_type == REGULAR_TORRENT).limit(1)) + if results: + return results[0] + return None - db_results = self.torrent_db.searchNames(query, local=True, - keys=['infohash', 'T.name', 'T.length', 'T.num_files', 'T.category', - 'T.creation_date', 'T.num_seeders', 'T.num_leechers']) - if not db_results: - return [] - - results = [] - for dbresult in db_results: - channel_details = dbresult[-10:] - - dbresult = list(dbresult[:8]) - dbresult[2] = long(dbresult[2]) # length - dbresult[3] = int(dbresult[3]) # num_files - dbresult[4] = [dbresult[4]] # category - dbresult[5] = long(dbresult[5]) # creation_date - dbresult[6] = int(dbresult[6] or 0) # num_seeders - dbresult[7] = int(dbresult[7] or 0) # num_leechers - - # cid - if channel_details[1]: - channel_details[1] = str(channel_details[1]) - dbresult.append(channel_details[1]) - - results.append(SearchResponseItemPayload(*tuple(dbresult))) - - return results - - def search_channels(self, query): - """ - Search best channels for the given query. - :param query: Search query - :return: List - """ - db_channels = self.channel_db.search_in_local_channels_db(query) - if not db_channels: - return [] - - results = [] - if db_channels: - for channel in db_channels: - channel_payload = channel[:8] - channel_payload[7] = channel[8] # modified - results.append(ChannelItemPayload(*channel_payload)) - return results - - def update_from_torrent_search_results(self, search_results): - """ - Updates the torrent database with the provided search results. It also checks for conflicting torrents, meaning - if torrent already exists in the database, we simply ignore the search result. - """ - for result in search_results: - (infohash, name, length, num_files, category_list, creation_date, seeders, leechers, cid) = result - name = u''.join([six.unichr(ord(c)) for c in name]) - torrent_item = SearchResponseItemPayload(infohash, name, length, num_files, category_list, - creation_date, seeders, leechers, cid) - if self.has_torrent(infohash): - db_torrent = self.get_torrent(infohash) - if db_torrent['name'] and db_torrent['name'] == torrent_item.name: - self.logger.info("Conflicting names for torrent. Ignoring the response") - continue - else: - self.logger.debug("Adding new torrent from search results to database") - self.torrent_db.addOrGetTorrentID(infohash) - self.torrent_db._db.commit_now() - - # Update local database - self.torrent_db.updateTorrent(infohash, notify=False, name=torrent_item.name, - length=torrent_item.length, - creation_date=torrent_item.creation_date, - num_files=torrent_item.num_files, - seeder=seeders, - leecher=leechers, - comment='') - - def update_from_channel_search_results(self, all_items): - """ - TODO: updates the channel database with the search results. - Waiting for all channel 2.0 - """ - pass + def has_torrent(self, infohash): + return self.get_torrent(infohash) is not None diff --git a/Tribler/community/search/__init__.py b/Tribler/community/search/__init__.py deleted file mode 100644 index 4a117311fe6..00000000000 --- a/Tribler/community/search/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -A Dispersy community used to implement decentralized search in Tribler. It allows peers to discover new .torrents. -""" diff --git a/Tribler/community/search/community.py b/Tribler/community/search/community.py deleted file mode 100644 index 5efe96f2061..00000000000 --- a/Tribler/community/search/community.py +++ /dev/null @@ -1,738 +0,0 @@ -""" -Peer to peer search functionality and discovering new torrents. - -Author(s): Niels Zeilemaker -""" -from binascii import hexlify -from random import shuffle -from time import time -from traceback import print_exc -from twisted.internet.task import LoopingCall - -from Tribler.Core.CacheDB.sqlitecachedb import bin2str -from Tribler.Core.TorrentDef import TorrentDef -from Tribler.community.channel.payload import TorrentPayload -from Tribler.community.channel.preview import PreviewChannelCommunity -from Tribler.community.search.conversion import SearchConversion -from Tribler.community.search.payload import (SearchRequestPayload, SearchResponsePayload, TorrentRequestPayload, - TorrentCollectRequestPayload, TorrentCollectResponsePayload, - TasteIntroPayload) -from Tribler.dispersy.authentication import MemberAuthentication -from Tribler.dispersy.bloomfilter import BloomFilter -from Tribler.dispersy.candidate import CANDIDATE_WALK_LIFETIME, WalkCandidate -from Tribler.dispersy.community import Community -from Tribler.dispersy.conversion import DefaultConversion -from Tribler.dispersy.database import IgnoreCommits -from Tribler.dispersy.destination import CandidateDestination, CommunityDestination -from Tribler.dispersy.distribution import DirectDistribution, FullSyncDistribution -from Tribler.dispersy.exception import CommunityNotFoundException -from Tribler.dispersy.message import Message -from Tribler.dispersy.requestcache import RandomNumberCache, IntroductionRequestCache -from Tribler.dispersy.resolution import PublicResolution - -DEBUG = False -SWIFT_INFOHASHES = 0 -CREATE_TORRENT_COLLECT_INTERVAL = 5 - - -class SearchCommunity(Community): - - """ - A single community that all Tribler members join and use to disseminate .torrent files. - """ - @classmethod - def get_master_members(cls, dispersy): -# generated: Mon Nov 24 10:37:11 2014 -# curve: NID_sect571r1 -# len: 571 bits ~ 144 bytes signature -# pub: 170 3081a7301006072a8648ce3d020106052b810400270381920004034a9031d07ed6d5d98b0a98cacd4bef2e19125ea7635927708babefa8e66deeb6cb4e78cc0efda39a581a679032a95ebc4a0fbdf913aa08af31f14753839b620cb5547c6e6cf42f03629b1b3dc199a3b1a262401c7ae615e87a1cf13109c7fb532f45c492ba927787257bf994e989a15fb16f20751649515fc58d87e0c861ca5b467a5c450bf57f145743d794057e75 -# pub-sha1 fb04df93369587ec8fd9b74559186fa356cffda8 -# -----BEGIN PUBLIC KEY----- -# MIGnMBAGByqGSM49AgEGBSuBBAAnA4GSAAQDSpAx0H7W1dmLCpjKzUvvLhkSXqdj -# WSdwi6vvqOZt7rbLTnjMDv2jmlgaZ5AyqV68Sg+9+ROqCK8x8UdTg5tiDLVUfG5s -# 9C8DYpsbPcGZo7GiYkAceuYV6Hoc8TEJx/tTL0XEkrqSd4cle/mU6YmhX7FvIHUW -# SVFfxY2H4MhhyltGelxFC/V/FFdD15QFfnU= -# -----END PUBLIC KEY----- - master_key = "3081a7301006072a8648ce3d020106052b810400270381920004034a9031d07ed6d5d98b0a98cacd4bef2e19125ea7635927708babefa8e66deeb6cb4e78cc0efda39a581a679032a95ebc4a0fbdf913aa08af31f14753839b620cb5547c6e6cf42f03629b1b3dc199a3b1a262401c7ae615e87a1cf13109c7fb532f45c492ba927787257bf994e989a15fb16f20751649515fc58d87e0c861ca5b467a5c450bf57f145743d794057e75".decode("HEX") - master = dispersy.get_member(public_key=master_key) - return [master] - - def __init__(self, *args, **kwargs): - super(SearchCommunity, self).__init__(*args, **kwargs) - self.tribler_session = None - self.integrate_with_tribler = None - self.log_incoming_searches = None - self.taste_buddies = [] - - self._channelcast_db = None - self._torrent_db = None - self._mypref_db = None - self._notifier = None - - self._rtorrent_handler = None - - self.taste_bloom_filter = None - self.taste_bloom_filter_key = None - - self.torrent_cache = None - - def initialize(self, tribler_session=None, log_incoming_searches=False): - self.tribler_session = tribler_session - self.integrate_with_tribler = tribler_session is not None - self.log_incoming_searches = log_incoming_searches - - super(SearchCommunity, self).initialize() - # To always connect to a peer uncomment/modify the following line - # self.taste_buddies.append([1, time(), Candidate(("127.0.0.1", 1234), False)) - - if self.integrate_with_tribler: - from Tribler.Core.simpledefs import NTFY_CHANNELCAST, NTFY_TORRENTS, NTFY_MYPREFERENCES - - # tribler channelcast database - self._channelcast_db = tribler_session.open_dbhandler(NTFY_CHANNELCAST) - self._torrent_db = tribler_session.open_dbhandler(NTFY_TORRENTS) - self._mypref_db = tribler_session.open_dbhandler(NTFY_MYPREFERENCES) - self._notifier = tribler_session.notifier - - # torrent collecting - self._rtorrent_handler = tribler_session.lm.rtorrent_handler - else: - self._channelcast_db = ChannelCastDBStub(self._dispersy) - self._torrent_db = None - self._mypref_db = None - self._notifier = None - - self.register_task(u"create torrent collect requests", - LoopingCall(self.create_torrent_collect_requests)).start(CREATE_TORRENT_COLLECT_INTERVAL, - now=True) - - def initiate_meta_messages(self): - return super(SearchCommunity, self).initiate_meta_messages() + [ - Message(self, u"search-request", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - SearchRequestPayload(), - self._generic_timeline_check, - self.on_search), - Message(self, u"search-response", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - SearchResponsePayload(), - self._generic_timeline_check, - self.on_search_response), - Message(self, u"torrent-request", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - TorrentRequestPayload(), - self._generic_timeline_check, - self.on_torrent_request), - Message(self, u"torrent-collect-request", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - TorrentCollectRequestPayload(), - self._generic_timeline_check, - self.on_torrent_collect_request), - Message(self, u"torrent-collect-response", - MemberAuthentication(), - PublicResolution(), - DirectDistribution(), - CandidateDestination(), - TorrentCollectResponsePayload(), - self._generic_timeline_check, - self.on_torrent_collect_response), - Message(self, u"torrent", - MemberAuthentication(), - PublicResolution(), - FullSyncDistribution(enable_sequence_number=False, synchronization_direction=u"ASC", priority=128), - CommunityDestination(node_count=0), - TorrentPayload(), - self._generic_timeline_check, - self.on_torrent), - ] - - def _initialize_meta_messages(self): - super(SearchCommunity, self)._initialize_meta_messages() - - ori = self._meta_messages[u"dispersy-introduction-request"] - new = Message(self, ori.name, ori.authentication, ori.resolution, ori.distribution, ori.destination, TasteIntroPayload(), ori.check_callback, ori.handle_callback) - self._meta_messages[u"dispersy-introduction-request"] = new - - def initiate_conversions(self): - return [DefaultConversion(self), SearchConversion(self)] - - @property - def dispersy_enable_fast_candidate_walker(self): - return self.integrate_with_tribler - - @property - def dispersy_auto_download_master_member(self): - # there is no dispersy-identity for the master member, so don't try to download - return False - - @property - def dispersy_enable_bloom_filter_sync(self): - # 1. disable bloom filter sync in walker - # 2. accept messages in any global time range - return False - - def add_taste_buddies(self, new_taste_buddies): - for new_tb_tuple in new_taste_buddies[:]: - for tb_tuple in self.taste_buddies: - if tb_tuple[-1].sock_addr == new_tb_tuple[-1].sock_addr: - - # update similarity - tb_tuple[0] = max(new_tb_tuple[0], tb_tuple[0]) - new_taste_buddies.remove(new_tb_tuple) - break - else: - self.taste_buddies.append(new_tb_tuple) - - self.taste_buddies.sort(reverse=True) - self.taste_buddies = self.taste_buddies[:10] - - # Send ping to all new candidates - if len(new_taste_buddies) > 0: - self.create_torrent_collect_requests([tb_tuple[-1] for tb_tuple in new_taste_buddies]) - - def get_nr_connections(self): - return len(self.get_connections()) - - def get_connections(self): - # add 10 taste buddies and 20 - len(taste_buddies) to candidates - candidates = set(candidate for _, _, candidate in self.taste_buddies) - sock_addresses = set(candidate.sock_addr for _, _, candidate in self.taste_buddies) - - for candidate in self.dispersy_yield_verified_candidates(): - if candidate.sock_addr not in sock_addresses: - candidates.add(candidate) - sock_addresses.add(candidate.sock_addr) - - if len(candidates) == 20: - break - return candidates - - def __calc_similarity(self, candidate, myPrefs, hisPrefs, overlap): - if myPrefs > 0 and hisPrefs > 0: - my_root = 1.0 / (myPrefs ** .5) - sim = overlap * (my_root * (1.0 / (hisPrefs ** .5))) - return [sim, time(), candidate] - - return [0, time(), candidate] - - def create_introduction_request(self, destination, allow_sync, is_fast_walker=False): - assert isinstance(destination, WalkCandidate), [type(destination), destination] - - if DEBUG: - self._logger.debug(u"SearchCommunity: sending introduction request to %s", destination) - - advice = True - if not is_fast_walker: - my_preferences = sorted(self._mypref_db.getMyPrefListInfohash(limit=500)) - num_preferences = len(my_preferences) - - my_pref_key = u",".join(map(bin2str, my_preferences)) - if my_pref_key != self.taste_bloom_filter_key: - if num_preferences > 0: - # no prefix changing, we want false positives (make sure it is a single char) - self.taste_bloom_filter = BloomFilter(0.005, len(my_preferences), prefix=' ') - self.taste_bloom_filter.add_keys(my_preferences) - else: - self.taste_bloom_filter = None - - self.taste_bloom_filter_key = my_pref_key - - taste_bloom_filter = self.taste_bloom_filter - - cache = self._request_cache.add(IntroductionRequestCache(self, destination)) - payload = (destination.sock_addr, self._dispersy._lan_address, self._dispersy._wan_address, advice, self._dispersy._connection_type, None, cache.number, num_preferences, taste_bloom_filter) - else: - cache = self._request_cache.add(IntroductionRequestCache(self, destination)) - payload = (destination.sock_addr, self._dispersy._lan_address, self._dispersy._wan_address, advice, self._dispersy._connection_type, None, cache.number, 0, None) - - destination.walk(time()) - self.add_candidate(destination) - - meta_request = self.get_meta_message(u"dispersy-introduction-request") - request = meta_request.impl(authentication=(self.my_member,), - distribution=(self.global_time,), - destination=(destination,), - payload=payload) - - self._logger.debug(u"%s %s sending introduction request to %s", self.cid.encode("HEX"), type(self), destination) - - self._dispersy._forward([request]) - return request - - def on_introduction_request(self, messages): - super(SearchCommunity, self).on_introduction_request(messages) - - if any(message.payload.taste_bloom_filter for message in messages): - my_preferences = self._mypref_db.getMyPrefListInfohash(limit=500) - else: - my_preferences = [] - - new_taste_buddies = [] - for message in messages: - taste_bloom_filter = message.payload.taste_bloom_filter - num_preferences = message.payload.num_preferences - if taste_bloom_filter: - overlap = sum(infohash in taste_bloom_filter for infohash in my_preferences) - else: - overlap = 0 - - new_taste_buddies.append(self.__calc_similarity(message.candidate, len(my_preferences), num_preferences, overlap)) - - if len(new_taste_buddies) > 0: - self.add_taste_buddies(new_taste_buddies) - - if self._notifier: - from Tribler.Core.simpledefs import NTFY_ACT_MEET, NTFY_ACTIVITIES, NTFY_INSERT - for message in messages: - self._notifier.notify(NTFY_ACTIVITIES, NTFY_INSERT, NTFY_ACT_MEET, - "%s:%d" % message.candidate.sock_addr) - - class SearchRequest(RandomNumberCache): - - def __init__(self, request_cache, keywords): - super(SearchCommunity.SearchRequest, self).__init__(request_cache, u"search") - self.keywords = keywords - - @property - def timeout_delay(self): - return 30.0 - - def on_timeout(self): - pass - - def create_search(self, keywords): - candidates = self.get_connections() - if len(candidates) > 0: - if DEBUG: - self._logger.debug(u"sending search request for %s to %s", keywords, map(str, candidates)) - - # register callback/fetch identifier - cache = self._request_cache.add(SearchCommunity.SearchRequest(self._request_cache, keywords)) - - # create search request message - meta = self.get_meta_message(u"search-request") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), payload=(cache.number, keywords)) - - self._dispersy._send(candidates, [message]) - - return len(candidates) - - def on_search(self, messages): - for message in messages: - keywords = message.payload.keywords - - if DEBUG: - self._logger.debug(u"got search request for %s", keywords) - - if self.log_incoming_searches: - self.log_incoming_searches(message.candidate.sock_addr, keywords) - - results = [] - dbresults = self._torrent_db.searchNames(keywords, local=False, keys=['infohash', 'T.name', 'T.length', 'T.num_files', 'T.category', 'T.creation_date', 'T.num_seeders', 'T.num_leechers']) - if len(dbresults) > 0: - for dbresult in dbresults: - channel_details = dbresult[-10:] - - dbresult = list(dbresult[:8]) - dbresult[2] = long(dbresult[2]) # length - dbresult[3] = int(dbresult[3]) # num_files - dbresult[4] = [dbresult[4]] # category - dbresult[5] = long(dbresult[5]) # creation_date - dbresult[6] = int(dbresult[6] or 0) # num_seeders - dbresult[7] = int(dbresult[7] or 0) # num_leechers - - # cid - if channel_details[1]: - channel_details[1] = str(channel_details[1]) - dbresult.append(channel_details[1]) - - results.append(tuple(dbresult)) - elif DEBUG: - self._logger.debug(u"no results") - - self._create_search_response(message.payload.identifier, results, message.candidate) - - def _create_search_response(self, identifier, results, candidate): - # create search-response message - meta = self.get_meta_message(u"search-response") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), payload=(identifier, results)) - self._dispersy._forward([message]) - - if DEBUG: - self._logger.debug(u"returning %s results to %s", len(results), candidate) - - def on_search_response(self, messages): - # _get_channel_community could cause multiple commits, using this with clause this is reduced to only one. - with self._dispersy.database: - for message in messages: - # fetch callback using identifier - search_request = self._request_cache.get(u"search", message.payload.identifier) - if search_request: - if DEBUG: - self._logger.debug(u"SearchCommunity: got search response for %s %s %s", - search_request.keywords, len(message.payload.results), message.candidate) - - if len(message.payload.results) > 0: - self._torrent_db.on_search_response(message.payload.results) - - # emit signal of search results - if self.tribler_session is not None: - from Tribler.Core.simpledefs import SIGNAL_SEARCH_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS - search_results = {'keywords': search_request.keywords, - 'results': message.payload.results, - 'candidate': message.candidate} - self._notifier.notify(SIGNAL_SEARCH_COMMUNITY, SIGNAL_ON_SEARCH_RESULTS, None, - search_results) - - # see if we need to join some channels - channels = set([result[8] for result in message.payload.results if result[8]]) - if channels: - channels = self._get_unknown_channels(channels) - - if DEBUG: - self._logger.debug(u"SearchCommunity: joining %d preview communities", len(channels)) - - for cid in channels: - community = self._get_channel_community(cid) - community.disp_create_missing_channel(message.candidate, includeSnapshot=False) - else: - if DEBUG: - self._logger.debug(u"SearchCommunity: got search response identifier not found %s", - message.payload.identifier) - - # ensure that no commits occur - raise IgnoreCommits() - - def create_torrent_request(self, infohash, candidate): - torrentdict = {} - torrentdict[self._master_member.mid] = set([infohash, ]) - - # create torrent-request message - meta = self.get_meta_message(u"torrent-request") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), payload=(torrentdict,)) - self._dispersy._forward([message]) - - if DEBUG: - nr_requests = sum([len(cid_torrents) for cid_torrents in torrentdict.values()]) - self._logger.debug(u"requesting %s TorrentMessages from %s", nr_requests, candidate) - - def on_torrent_request(self, messages): - for message in messages: - requested_packets = [] - for cid, torrents in message.payload.torrents.iteritems(): - requested_packets.extend(self._get_packets_from_infohashes(cid, torrents)) - - if requested_packets: - self._dispersy._send_packets([message.candidate], requested_packets, - self, u"-caused by on-torrent-request-") - - if DEBUG: - self._logger.debug(u"got request for %s torrents from %s", len(requested_packets), message.candidate) - - class PingRequestCache(RandomNumberCache): - - def __init__(self, community, candidate): - super(SearchCommunity.PingRequestCache, self).__init__(community._request_cache, u"ping") - - self.community = community - self.candidate = candidate - - @property - def timeout_delay(self): - return 10.5 - - def on_timeout(self): - refresh_if = time() - CANDIDATE_WALK_LIFETIME - remove = None - for taste_buddy in self.community.taste_buddies: - if taste_buddy[2] == self.candidate: - if taste_buddy[1] < refresh_if: - remove = taste_buddy - break - - if remove: - self.community.taste_buddies.remove(remove) - - def create_torrent_collect_requests(self, candidates=None): - if candidates is None: - refresh_if = time() - CANDIDATE_WALK_LIFETIME - # determine to which peers we need to send a ping - candidates = [candidate for _, prev, candidate in self.taste_buddies if prev < refresh_if] - - if len(candidates) > 0: - self._create_pingpong(u"torrent-collect-request", candidates) - - def on_torrent_collect_request(self, messages): - candidates = [message.candidate for message in messages] - identifiers = [message.payload.identifier for message in messages] - - self._create_pingpong(u"torrent-collect-response", candidates, identifiers) - self._process_collect_request_response(messages) - - def on_torrent_collect_response(self, messages): - self._process_collect_request_response(messages) - - def _process_collect_request_response(self, messages): - to_insert_list = [] - to_collect_dict = {} - to_popularity_dict = {} - for message in messages: - # check if the identifier is still in the request_cache because it could be timed out - if not self.request_cache.has(u"ping", message.payload.identifier): - self._logger.warn(u"message from %s cannot be found in the request cache, skipping it", - message.candidate) - continue - self.request_cache.pop(u"ping", message.payload.identifier) - - if message.payload.hashtype == SWIFT_INFOHASHES: - for infohash, seeders, leechers, ago in message.payload.torrents: - if not infohash: - continue - elif infohash not in to_insert_list: - to_insert_list.append(infohash) - to_popularity_dict[infohash] = [seeders, leechers, time() - (ago * 60)] - to_collect_dict.setdefault(infohash, []).append(message.candidate) - - if len(to_insert_list) > 0: - while to_insert_list: - self._torrent_db.on_torrent_collect_response(to_insert_list[:50]) - to_insert_list = to_insert_list[50:] - - infohashes_to_collect = [infohash for infohash in to_collect_dict - if infohash and not self.tribler_session.has_collected_torrent(infohash)] - if infohashes_to_collect: - for infohash in infohashes_to_collect[:5]: - for candidate in to_collect_dict[infohash]: - self._logger.debug(u"requesting .torrent after receiving ping/pong %s %s", - candidate, hexlify(infohash)) - - # low_prio changes, hence we need to import it here - from Tribler.Core.RemoteTorrentHandler import LOW_PRIO_COLLECTING - self._rtorrent_handler.download_torrent(candidate, infohash, priority=LOW_PRIO_COLLECTING, - timeout=CANDIDATE_WALK_LIFETIME) - - sock_addrs = [message.candidate.sock_addr for message in messages] - for taste_buddy in self.taste_buddies: - if taste_buddy[2].sock_addr in sock_addrs: - taste_buddy[1] = time() - - def _create_pingpong(self, meta_name, candidates, identifiers=None): - max_len = self.dispersy_sync_bloom_filter_bits / 8 - torrents = self.__get_torrents(int(max_len / 44)) - for index, candidate in enumerate(candidates): - if identifiers: - identifier = identifiers[index] - else: - cache = self._request_cache.add(SearchCommunity.PingRequestCache(self, candidate)) - identifier = cache.number - - # create torrent-collect-request/response message - meta = self.get_meta_message(meta_name) - message = meta.impl(authentication=(self._my_member,), - distribution=(self.global_time,), destination=(candidate,), - payload=(identifier, SWIFT_INFOHASHES, torrents)) - - self._dispersy._forward([message]) - self._logger.debug(u"send %s to %s", meta_name, candidate) - - def __get_torrents(self, limit): - cache_timeout = CANDIDATE_WALK_LIFETIME - if self.torrent_cache and self.torrent_cache[0] > (time() - cache_timeout): - return self.torrent_cache[1] - - # we want roughly 1/3 random, 2/3 recent - limit_recent = int(limit * 0.66) - limit_random = limit - limit_recent - - torrents = self._torrent_db.getRecentlyCollectedTorrents(limit=limit_recent) or [] - if len(torrents) == limit_recent: - # index 4 is insert_time - least_recent = torrents[-1][4] - random_torrents = self._torrent_db.getRandomlyCollectedTorrents(least_recent, limit=limit_random) or [] - else: - random_torrents = [] - - torrents = [[tor[0], tor[1], tor[2], tor[3]] for tor in torrents] - random_torrents = [[tor[0], tor[1], tor[2], tor[3]] for tor in random_torrents] - - # combine random and recent + shuffle to obscure categories - torrents = torrents + random_torrents - shuffle(torrents) - - # fix leechers, seeders to max 2**16 (shift values +2 to accomodate -2 and -1 values) - max_value = (2 ** 16) - 1 - for torrent in torrents: - # index 1 and 2 are num_seeders and num_leechers respectively - torrent[1] = min(max_value, (torrent[1] or -1) + 2) - torrent[2] = min(max_value, (torrent[2] or -1) + 2) - - # index 3 is last_tracker_check, convert to minutes - torrent[3] /= 60 - if torrent[3] > max_value or torrent[3] < 0: - torrent[3] = max_value - - self.torrent_cache = (time(), torrents) - return torrents - - def create_torrent(self, infohash, store=True, update=True, forward=True): - torrent_data = self.tribler_session.get_collected_torrent(infohash) - if torrent_data is not None: - try: - torrentdef = TorrentDef.load_from_memory(torrent_data) - files = torrentdef.get_files_with_length() - - meta = self.get_meta_message(u"torrent") - message = meta.impl(authentication=(self._my_member,), - distribution=(self.claim_global_time(),), - payload=(torrentdef.get_infohash(), long(time()), torrentdef.get_name_as_unicode(), - tuple(files), torrentdef.get_trackers_as_single_tuple())) - - self._dispersy.store_update_forward([message], store, update, forward) - self._torrent_db.updateTorrent(torrentdef.get_infohash(), notify=False, dispersy_id=message.packet_id) - - return message - except ValueError: - pass - except: - print_exc() - return False - - def on_torrent(self, messages): - for message in messages: - self._torrent_db.addExternalTorrentNoDef(message.payload.infohash, message.payload.name, message.payload.files, message.payload.trackers, message.payload.timestamp, {'dispersy_id': message.packet_id}) - - def _get_channel_id(self, cid): - assert isinstance(cid, str) - assert len(cid) == 20 - - return self._channelcast_db._db.fetchone(u"SELECT id FROM Channels WHERE dispersy_cid = ?", (buffer(cid),)) - - def _get_unknown_channels(self, cids): - assert all(isinstance(cid, str) for cid in cids) - assert all(len(cid) == 20 for cid in cids) - - parameters = u",".join(["?"] * len(cids)) - known_cids = self._channelcast_db._db.fetchall(u"SELECT dispersy_cid FROM Channels WHERE dispersy_cid in (" + parameters + u")", map(buffer, cids)) - known_cids = map(str, known_cids) - return [cid for cid in cids if cid not in known_cids] - - def _get_channel_community(self, cid): - assert isinstance(cid, str) - assert len(cid) == 20 - - try: - return self._dispersy.get_community(cid, True) - except CommunityNotFoundException: - self._logger.debug(u"join preview community %s", cid.encode("HEX")) - return PreviewChannelCommunity.init_community(self._dispersy, self._dispersy.get_member(mid=cid), - self._my_member, tribler_session=self.tribler_session) - - def _get_packets_from_infohashes(self, cid, infohashes): - packets = [] - - def add_packet(dispersy_id): - if dispersy_id and dispersy_id > 0: - try: - packet = self._get_packet_from_dispersy_id(dispersy_id, "torrent") - if packet: - packets.append(packet) - except RuntimeError: - pass - - if cid == self._master_member.mid: - channel_id = None - else: - channel_id = self._get_channel_id(cid) - - for infohash in infohashes: - dispersy_id = None - - # 1. try to find the torrentmessage for this cid, infohash combination - if channel_id: - dispersy_id = self._channelcast_db.getTorrentFromChannelId(channel_id, infohash, ['ChannelTorrents.dispersy_id']) - else: - torrent = self._torrent_db.getTorrent(infohash, ['dispersy_id'], include_mypref=False) - if torrent: - dispersy_id = torrent['dispersy_id'] - - # 2. if still not found, create a new torrentmessage and return this one - if not dispersy_id: - message = self.create_torrent(infohash, store=True, update=False, forward=False) - if message: - packets.append(message.packet) - add_packet(dispersy_id) - return packets - - def _get_packet_from_dispersy_id(self, dispersy_id, messagename): - # 1. get the packet - try: - packet, _ = self._dispersy.database.execute(u"SELECT sync.packet, sync.id FROM community JOIN sync ON sync.community = community.id WHERE sync.id = ?", (dispersy_id,)).next() - except StopIteration: - raise RuntimeError(u"Unknown dispersy_id") - - return str(packet) - - -class ChannelCastDBStub(object): - - def __init__(self, dispersy): - self._dispersy = dispersy - - self.cachedTorrents = None - - def convert_to_messages(self, results): - messages = self._dispersy.convert_packets_to_messages(str(packet) for packet, _ in results) - for packet_id, message in zip((packet_id for _, packet_id in results), messages): - if message: - message.packet_id = packet_id - yield message.community.cid, message - - def newTorrent(self, message): - self._cachedTorrents[message.payload.infohash] = message - - def hasTorrents(self, channel_id, infohashes): - returnAr = [] - for infohash in infohashes: - if infohash in self._cachedTorrents: - returnAr.append(True) - else: - returnAr.append(False) - return returnAr - - def getTorrentFromChannelId(self, channel_id, infohash, keys): - if infohash in self._cachedTorrents: - return self._cachedTorrents[infohash].packet_id - - def on_dynamic_settings(self, channel_id): - pass - - @property - def _cachedTorrents(self): - if self.cachedTorrents is None: - self.cachedTorrents = {} - self._cacheTorrents() - - return self.cachedTorrents - - def _cacheTorrents(self): - sql = u"SELECT sync.packet, sync.id FROM sync JOIN meta_message ON sync.meta_message = meta_message.id JOIN community ON community.id = sync.community WHERE meta_message.name = 'torrent'" - results = list(self._dispersy.database.execute(sql)) - messages = self.convert_to_messages(results) - - for _, message in messages: - self._cachedTorrents[message.payload.infohash] = message diff --git a/Tribler/community/search/conversion.py b/Tribler/community/search/conversion.py deleted file mode 100644 index 21b2f1c0a71..00000000000 --- a/Tribler/community/search/conversion.py +++ /dev/null @@ -1,332 +0,0 @@ -""" -Data conversions for the search community. - -Author(s): Niels Zeilemaker -""" -import zlib -from math import ceil -from random import choice, sample -from struct import pack, unpack_from - -from Tribler.dispersy.bloomfilter import BloomFilter -from Tribler.dispersy.conversion import BinaryConversion -from Tribler.dispersy.message import DropPacket -from Tribler.pyipv8.ipv8.messaging.deprecated.encoding import encode, decode - - -class SearchConversion(BinaryConversion): - - def __init__(self, community): - super(SearchConversion, self).__init__(community, "\x02") - self.define_meta_message(chr(1), community.get_meta_message(u"search-request"), self._encode_search_request, self._decode_search_request) - self.define_meta_message(chr(2), community.get_meta_message(u"search-response"), self._encode_search_response, self._decode_search_response) - self.define_meta_message(chr(3), community.get_meta_message(u"torrent-request"), self._encode_torrent_request, self._decode_torrent_request) - self.define_meta_message(chr(4), community.get_meta_message(u"torrent-collect-request"), self._encode_torrent_collect_request, self._decode_torrent_collect_request) - self.define_meta_message(chr(5), community.get_meta_message(u"torrent-collect-response"), self._encode_torrent_collect_response, self._decode_torrent_collect_response) - self.define_meta_message(chr(6), community.get_meta_message(u"torrent"), self._encode_torrent, self._decode_torrent) - - def _encode_introduction_request(self, message): - data = BinaryConversion._encode_introduction_request(self, message) - - if message.payload.taste_bloom_filter: - data.extend((pack('!IBH', message.payload.num_preferences, message.payload.taste_bloom_filter.functions, message.payload.taste_bloom_filter.size), message.payload.taste_bloom_filter.prefix, message.payload.taste_bloom_filter.bytes)) - return data - - def _decode_introduction_request(self, placeholder, offset, data): - offset, payload = BinaryConversion._decode_introduction_request(self, placeholder, offset, data) - - # if there's still bytes in this request, treat them as taste_bloom_filter - has_stuff = len(data) > offset - if has_stuff: - if len(data) < offset + 8: - raise DropPacket("Insufficient packet size") - - num_preferences, functions, size = unpack_from('!IBH', data, offset) - offset += 7 - - prefix = data[offset] - offset += 1 - - if not 0 < num_preferences: - raise DropPacket("Invalid num_preferences value") - if not 0 < functions: - raise DropPacket("Invalid functions value") - if not 0 < size: - raise DropPacket("Invalid size value") - if not size % 8 == 0: - raise DropPacket("Invalid size value, must be a multiple of eight") - - length = int(ceil(size / 8)) - if not length == len(data) - offset: - raise DropPacket("Invalid number of bytes available (irq) %d, %d, %d" % (length, len(data) - offset, size)) - - taste_bloom_filter = BloomFilter(data[offset:offset + length], functions, prefix=prefix) - offset += length - - payload.set_num_preferences(num_preferences) - payload.set_taste_bloom_filter(taste_bloom_filter) - - return offset, payload - - def _encode_search_request(self, message): - packet = pack('!H', message.payload.identifier), message.payload.keywords - if message.payload.bloom_filter: - packet = packet + (message.payload.bloom_filter.functions, message.payload.bloom_filter.prefix, message.payload.bloom_filter.bytes) - packet = encode(packet) - return packet, - - def _decode_search_request(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decodr 21, 2012 e the search-payload") - - if len(payload) < 2: - raise DropPacket("Invalid payload length") - - identifier, keywords = payload[:2] - - if len(identifier) != 2: - raise DropPacket("Unable to decode the search-payload, got %d bytes expected 2" % (len(identifier))) - identifier, = unpack_from('!H', identifier) - - if not isinstance(keywords, list): - raise DropPacket("Invalid 'keywords' type") - for keyword in keywords: - if not isinstance(keyword, unicode): - raise DropPacket("Invalid 'keyword' type") - - if len(payload) > 5: - functions, prefix, bytes_ = payload[2:6] - - if not isinstance(functions, int): - raise DropPacket("Invalid functions type") - if not 0 < functions: - raise DropPacket("Invalid functions value") - - size = len(bytes_) - if not 0 < size: - raise DropPacket("Invalid size of bloomfilter") - if not size % 8 == 0: - raise DropPacket("Invalid size of bloomfilter, must be a multiple of eight") - - if not isinstance(prefix, str): - raise DropPacket("Invalid prefix type") - if not 0 <= len(prefix) < 256: - raise DropPacket("Invalid prefix length") - - bloom_filter = BloomFilter(bytes_, functions, prefix=prefix) - else: - bloom_filter = None - - return offset, placeholder.meta.payload.implement(identifier, keywords, bloom_filter) - - def _encode_search_response(self, message): - packet = pack('!H', message.payload.identifier), message.payload.results - return encode(packet), - - def _decode_search_response(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except (ValueError, KeyError): - raise DropPacket("Unable to decode the search-reponse-payload") - - if len(payload) < 2: - raise DropPacket("Invalid payload length") - - identifier, results = payload[:2] - - if len(identifier) != 2: - raise DropPacket("Unable to decode the search-response-payload, got %d bytes expected 2" % (len(identifier))) - identifier, = unpack_from('!H', identifier) - - if not isinstance(results, list): - raise DropPacket("Invalid 'results' type") - - for result in results: - if not isinstance(result, tuple): - raise DropPacket("Invalid result type") - - if len(result) < 9: - raise DropPacket("Invalid result length") - - infohash, swarmname, length, nrfiles, category_list, creation_date, seeders, leechers, cid = result[:9] - - if not isinstance(infohash, str): - raise DropPacket("Invalid infohash type") - if len(infohash) != 20: - raise DropPacket("Invalid infohash length") - - if not isinstance(swarmname, unicode): - raise DropPacket("Invalid swarmname type") - - if not isinstance(length, long): - raise DropPacket("Invalid length type '%s'" % type(length)) - - if not isinstance(nrfiles, int): - raise DropPacket("Invalid nrfiles type") - - if not isinstance(category_list, list) or not all(isinstance(key, unicode) for key in category_list): - raise DropPacket("Invalid category_list type") - - if not isinstance(creation_date, long): - raise DropPacket("Invalid creation_date type") - - if not isinstance(seeders, int): - raise DropPacket("Invalid seeders type '%s'" % type(seeders)) - - if not isinstance(leechers, int): - raise DropPacket("Invalid leechers type '%s'" % type(leechers)) - - if cid: - if not isinstance(cid, str): - raise DropPacket("Invalid cid type") - - if len(cid) != 20: - raise DropPacket("Invalid cid length") - - return offset, placeholder.meta.payload.implement(identifier, results) - - def _encode_torrent_request(self, message): - max_len = self._community.dispersy_sync_bloom_filter_bits / 8 - - def create_msg(): - return encode(message.payload.torrents) - - packet = create_msg() - while len(packet) > max_len: - community = choice(message.payload.torrents.keys()) - nrTorrents = len(message.payload.torrents[community]) - if nrTorrents == 1: - del message.payload.torrents[community] - else: - message.payload.torrents[community] = set(sample(message.payload.torrents[community], nrTorrents - 1)) - - packet = create_msg() - return packet, - - def _decode_torrent_request(self, placeholder, offset, data): - try: - offset, payload = decode(data, offset) - except ValueError: - raise DropPacket("Unable to decode the torrent-request") - - if not isinstance(payload, dict): - raise DropPacket("Invalid payload type") - - for cid, infohashes in payload.iteritems(): - if not (isinstance(cid, str) and len(cid) == 20): - raise DropPacket("Invalid 'cid' type or value") - - for infohash in infohashes: - if not (isinstance(infohash, str) and len(infohash) == 20): - raise DropPacket("Invalid 'infohash' type or value") - return offset, placeholder.meta.payload.implement(payload) - - def _encode_torrent_collect_request(self, message): - for torrent in message.payload.torrents: - if torrent[1] > 2 ** 16 or torrent[1] < 0: - self._logger.info("seeder value is incorrect %s", torrent[1]) - if torrent[2] > 2 ** 16 or torrent[2] < 0: - self._logger.info("leecher value is incorrect %s", torrent[2]) - if torrent[3] > 2 ** 16 or torrent[3] < 0: - self._logger.info("since value is incorrect %s", torrent[3]) - - hashpack = '20sHHH' * len(message.payload.torrents) - torrents = [item for sublist in message.payload.torrents for item in sublist] - return pack('!HH' + hashpack, message.payload.identifier, message.payload.hashtype, *torrents), - - def _decode_torrent_collect_request(self, placeholder, offset, data): - if len(data) < offset + 4: - raise DropPacket("Insufficient packet size") - - identifier, hashtype = unpack_from('!HH', data, offset) - offset += 4 - - length = len(data) - offset - if length % 26 != 0: - raise DropPacket("Invalid number of bytes available (tcr)") - - if length: - hashpack = '20sHHH' * (length / 26) - hashes = unpack_from('!' + hashpack, data, offset) - offset += length - - torrents = [] - for i in range(0, len(hashes), 4): - torrents.append([hashes[i], hashes[i + 1], hashes[i + 2], hashes[i + 3]]) - else: - torrents = [] - return offset, placeholder.meta.payload.implement(identifier, hashtype, torrents) - - def _encode_torrent_collect_response(self, message): - return self._encode_torrent_collect_request(message) - - def _decode_torrent_collect_response(self, placeholder, offset, data): - return self._decode_torrent_collect_request(placeholder, offset, data) - - def _encode_torrent(self, message): - max_len = self._community.dispersy_sync_bloom_filter_bits / 8 - - files = message.payload.files - trackers = message.payload.trackers - - def create_msg(): - normal_msg = pack('!20sQ', message.payload.infohash, message.payload.timestamp), message.payload.name, tuple(files), tuple(trackers) - normal_msg = encode(normal_msg) - return zlib.compress(normal_msg) - - compressed_msg = create_msg() - while len(compressed_msg) > max_len: - if len(trackers) > 10: - # only use first 10 trackers, .torrents in the wild have been seen to have 1000+ trackers... - trackers = trackers[:10] - else: - # reduce files by the amount we are currently to big - reduce_by = max_len / (len(compressed_msg) * 1.0) - nr_files_to_include = int(len(files) * reduce_by) - files = sample(files, nr_files_to_include) - - compressed_msg = create_msg() - return compressed_msg, - - def _decode_torrent(self, placeholder, offset, data): - uncompressed_data = zlib.decompress(data[offset:]) - offset = len(data) - - try: - _, values = decode(uncompressed_data) - except ValueError: - raise DropPacket("Unable to decode the torrent-payload") - - infohash_time, name, files, trackers = values - if len(infohash_time) != 28: - raise DropPacket("Unable to decode the torrent-payload, got %d bytes expected 28" % (len(infohash_time))) - infohash, timestamp = unpack_from('!20sQ', infohash_time) - - if not isinstance(name, unicode): - raise DropPacket("Invalid 'name' type") - - if not isinstance(files, tuple): - raise DropPacket("Invalid 'files' type") - - if len(files) == 0: - raise DropPacket("Should have at least one file") - - for file in files: - if len(file) != 2: - raise DropPacket("Invalid 'file_len' type") - - path, length = file - if not isinstance(path, unicode): - raise DropPacket("Invalid 'files_path' type is %s" % type(path)) - if not isinstance(length, (int, long)): - raise DropPacket("Invalid 'files_length' type is %s" % type(length)) - - if not isinstance(trackers, tuple): - raise DropPacket("Invalid 'trackers' type") - for tracker in trackers: - if not isinstance(tracker, str): - raise DropPacket("Invalid 'tracker' type") - - return offset, placeholder.meta.payload.implement(infohash, timestamp, name, files, trackers) diff --git a/Tribler/community/search/payload.py b/Tribler/community/search/payload.py deleted file mode 100644 index e05a8941ffb..00000000000 --- a/Tribler/community/search/payload.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Dispersy Payload implementation for the search community. - -Author(s): Niels Zeilemaker -""" -from Tribler.dispersy.bloomfilter import BloomFilter -from Tribler.dispersy.payload import Payload, IntroductionRequestPayload - - -class TasteIntroPayload(IntroductionRequestPayload): - - class Implementation(IntroductionRequestPayload.Implementation): - - def __init__(self, meta, destination_address, source_lan_address, source_wan_address, advice, connection_type, sync, identifier, num_preferences=0, taste_bloom_filter=None): - IntroductionRequestPayload.Implementation.__init__(self, meta, destination_address, source_lan_address, source_wan_address, advice, connection_type, sync, identifier) - - self._num_preferences = num_preferences - self._taste_bloom_filter = taste_bloom_filter - - def set_num_preferences(self, num_preferences): - self._num_preferences = num_preferences - - def set_taste_bloom_filter(self, taste_bloom_filter): - self._taste_bloom_filter = taste_bloom_filter - - @property - def num_preferences(self): - return self._num_preferences - - @property - def taste_bloom_filter(self): - return self._taste_bloom_filter - - -class SearchRequestPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, identifier, keywords, bloom_filter=None): - if __debug__: - assert isinstance(identifier, int), type(identifier) - assert isinstance(keywords, list), 'keywords should be list' - for keyword in keywords: - assert isinstance(keyword, unicode), '%s is type %s' % (keyword, type(keyword)) - assert len(keyword) > 0 - - assert not bloom_filter or isinstance(bloom_filter, BloomFilter), type(bloom_filter) - - super(SearchRequestPayload.Implementation, self).__init__(meta) - self._identifier = identifier - self._keywords = keywords - self._bloom_filter = bloom_filter - - @property - def identifier(self): - return self._identifier - - @property - def keywords(self): - return self._keywords - - @property - def bloom_filter(self): - return self._bloom_filter - - -class SearchResponsePayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, identifier, results): - if __debug__: - assert isinstance(identifier, int), type(identifier) - assert isinstance(results, list), type(results) - for result in results: - assert isinstance(result, tuple), type(result) - assert len(result) > 8 - - infohash, swarmname, length, nrfiles, category_list, creation_date, seeders, leechers, cid = result[:9] - assert isinstance(infohash, str), type(infohash) - assert len(infohash) == 20 - assert isinstance(swarmname, unicode), type(swarmname) - assert isinstance(length, long), type(length) - assert isinstance(nrfiles, int), type(nrfiles) - assert isinstance(category_list, list), type(category_list) - assert all(isinstance(key, unicode) for key in category_list), category_list - assert isinstance(creation_date, long), type(creation_date) - assert isinstance(seeders, int), type(seeders) - assert isinstance(leechers, int), type(leechers) - assert not cid or isinstance(cid, str), type(cid) - assert not cid or len(cid) == 20, cid - - super(SearchResponsePayload.Implementation, self).__init__(meta) - self._identifier = identifier - self._results = results - - @property - def identifier(self): - return self._identifier - - @property - def results(self): - return self._results - - -class TorrentRequestPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, torrents): - if __debug__: - assert isinstance(torrents, dict), type(torrents) - for cid, infohashes in torrents.iteritems(): - assert isinstance(cid, str) - assert len(cid) == 20 - assert isinstance(infohashes, set) - assert not filter(lambda x: not isinstance(x, str), infohashes) - assert not filter(lambda x: not len(x) == 20, infohashes) - assert len(infohashes) > 0 - - super(TorrentRequestPayload.Implementation, self).__init__(meta) - self._torrents = torrents - - @property - def torrents(self): - return self._torrents - - -class TorrentCollectRequestPayload(Payload): - - class Implementation(Payload.Implementation): - - def __init__(self, meta, identifier, hashtype, torrents): - if __debug__: - assert isinstance(identifier, int), type(identifier) - assert isinstance(torrents, list), type(torrents) - for infohash, seeders, leechers, ago in torrents: - assert isinstance(infohash, str) - assert len(infohash) == 20, "%d, %s" % (len(infohash), infohash) - assert isinstance(seeders, int), type(seeders) - assert 0 <= seeders < 2 ** 16, seeders - assert isinstance(leechers, int), type(leechers) - assert 0 <= leechers < 2 ** 16, leechers - assert isinstance(ago, int), type(ago) - assert 0 <= ago < 2 ** 16, ago - - assert isinstance(hashtype, int), type(hashtype) - assert 0 <= hashtype < 2 ** 16, hashtype - - super(TorrentCollectRequestPayload.Implementation, self).__init__(meta) - - self._identifier = identifier - self._hashtype = hashtype - self._torrents = torrents - - @property - def identifier(self): - return self._identifier - - @property - def hashtype(self): - return self._hashtype - - @property - def torrents(self): - return self._torrents - - -class TorrentCollectResponsePayload(TorrentCollectRequestPayload): - pass diff --git a/Tribler/dispersy b/Tribler/dispersy deleted file mode 160000 index 23d0b846631..00000000000 --- a/Tribler/dispersy +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 23d0b84663138b58fb5c16ac75042b2de0a68ef5 diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index a05b5aea632..a01afbd112f 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -466,8 +466,6 @@ def received_settings(self, settings): self.video_player_page.video_player_port = settings["ports"]["video_server~port"] # Disable various components based on the settings - if not self.tribler_settings['search_community']['enabled']: - self.window().top_search_bar.setHidden(True) if not self.tribler_settings['video_server']['enabled']: self.left_menu_button_video_player.setHidden(True) self.downloads_creditmining_button.setHidden(not self.tribler_settings["credit_mining"]["enabled"]) diff --git a/debian/tribler.install b/debian/tribler.install index d8cc6d77139..d25bf6fbfbc 100644 --- a/debian/tribler.install +++ b/debian/tribler.install @@ -1,6 +1,5 @@ Tribler usr/share/tribler TriblerGUI usr/share/tribler -Tribler/Core/CacheDB/schema_sdb_v*.sql usr/share/tribler/Tribler Tribler/Main/Build/Ubuntu/tribler.desktop usr/share/applications Tribler/Main/Build/Ubuntu/tribler.xpm usr/share/pixmaps Tribler/Main/Build/Ubuntu/tribler_big.xpm usr/share/pixmaps diff --git a/logger.conf b/logger.conf index ba5a15feb5d..04cc156be80 100644 --- a/logger.conf +++ b/logger.conf @@ -5,22 +5,15 @@ keys=root,candidates,twisted, RequestCache, TriblerLaunchMany, - Dispersy, - Timeline, - IPv8toDispersyAdapter, - LibtorrentMgr, LibtorrentDownloadImpl, CreditMiningManager, CreditMiningSource, - SQLiteCacheDB, TrustChainDB, MarketDB, - AllChannelCommunity, DiscoveryCommunity, - SearchCommunity, HiddenTunnelCommunity, TriblerTunnelCommunity, TrustChainCommunity, @@ -96,12 +89,6 @@ args=(4*1024,) # 4KB buffer level=INFO handlers=default,infoMemoryHandler,errorHandler,debugging -[logger_candidates] -level=ERROR -qualname=dispersy-stats-detailed-candidates -handlers=default -propagate=0 - [logger_twisted] level=ERROR qualname=twisted-reactor @@ -125,24 +112,6 @@ qualname=TriblerLaunchMany handlers=default propagate=0 -[logger_Dispersy] -level=INFO -qualname=Dispersy -handlers=default -propagate=0 - -[logger_Timeline] -level=INFO -qualname=Timeline -handlers=default -propagate=0 - -[logger_IPv8toDispersyAdapter] -level=INFO -qualname=IPv8toDispersyAdapter -handlers=default -propagate=0 - [logger_LibtorrentMgr] level=INFO qualname=LibtorrentMgr @@ -169,12 +138,6 @@ propagate=0 ; *** database loggers *** -[logger_SQLiteCacheDB] -level=INFO -qualname=SQLiteCacheDB -handlers=default -propagate=0 - [logger_TrustChainDB] level=INFO qualname=TrustChainDB @@ -189,24 +152,12 @@ propagate=0 ; *** community loggers *** -[logger_AllChannelCommunity] -level=INFO -qualname=AllChannelCommunity -handlers=default -propagate=0 - [logger_DiscoveryCommunity] level=INFO qualname=DiscoveryCommunity handlers=default propagate=0 -[logger_SearchCommunity] -level=INFO -qualname=SearchCommunity -handlers=default -propagate=0 - [logger_HiddenTunnelCommunity] level=ERROR qualname=HiddenTunnelCommunity diff --git a/twisted/plugins/market_plugin.py b/twisted/plugins/market_plugin.py index 3d061a4083f..86ae55bf580 100644 --- a/twisted/plugins/market_plugin.py +++ b/twisted/plugins/market_plugin.py @@ -1,25 +1,24 @@ """ This twistd plugin enables to start Tribler headless using the twistd command. """ +from __future__ import absolute_import + import os import signal -from Tribler.Core.Config.tribler_config import TriblerConfig -from twisted.application.service import MultiService, IServiceMaker +from twisted.application.service import IServiceMaker, MultiService from twisted.conch import manhole_tap from twisted.internet import reactor from twisted.plugin import IPlugin from twisted.python import usage from twisted.python.log import msg + from zope.interface import implements +from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Modules.process_checker import ProcessChecker from Tribler.Core.Session import Session -# Register yappi profiler -from Tribler.community.market.community import MarketCommunity -from Tribler.dispersy.utils import twistd_yappi - class Options(usage.Options): optParameters = [ @@ -70,15 +69,10 @@ def signal_handler(sig, _): config = TriblerConfig() config.set_torrent_checking_enabled(False) - config.set_megacache_enabled(True) - config.set_dispersy_enabled(False) config.set_mainline_dht_enabled(True) - config.set_torrent_collecting_enabled(False) config.set_libtorrent_enabled(False) config.set_http_api_enabled(True) config.set_video_server_enabled(False) - config.set_torrent_search_enabled(False) - config.set_channel_search_enabled(False) config.set_credit_mining_enabled(False) config.set_dummy_wallets_enabled(True) config.set_popularity_community_enabled(False) diff --git a/twisted/plugins/tribler_plugin.py b/twisted/plugins/tribler_plugin.py index 538fe081b52..812f72e2769 100644 --- a/twisted/plugins/tribler_plugin.py +++ b/twisted/plugins/tribler_plugin.py @@ -1,30 +1,28 @@ """ This twistd plugin enables to start Tribler headless using the twistd command. """ -from socket import inet_aton -from datetime import date +from __future__ import absolute_import + import os +import re import signal import time +from datetime import date +from socket import inet_aton -import re -from twisted.application.service import MultiService, IServiceMaker +from twisted.application.service import IServiceMaker, MultiService from twisted.conch import manhole_tap from twisted.internet import reactor from twisted.plugin import IPlugin from twisted.python import usage from twisted.python.log import msg + from zope.interface import implements from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Modules.process_checker import ProcessChecker from Tribler.Core.Session import Session -# Register yappi profiler -from Tribler.community.allchannel.community import AllChannelCommunity -from Tribler.community.search.community import SearchCommunity -from Tribler.dispersy.utils import twistd_yappi - def check_ipv8_bootstrap_override(val): parsed = re.match(r"^([\d\.]+)\:(\d+)$", val) @@ -48,14 +46,12 @@ class Options(usage.Options): ["manhole", "m", 0, "Enable manhole telnet service listening at the specified port", int], ["statedir", "s", None, "Use an alternate statedir", str], ["restapi", "p", -1, "Use an alternate port for the REST API", int], - ["dispersy", "d", -1, "Use an alternate port for Dispersy", int], + ["ipv8", "i", -1, "Use an alternate port for IPv8", int], ["libtorrent", "l", -1, "Use an alternate port for libtorrent", int], ["ipv8_bootstrap_override", "b", None, "Force the usage of specific IPv8 bootstrap server (ip:port)", check_ipv8_bootstrap_override] ] optFlags = [ - ["auto-join-channel", "a", "Automatically join a channel when discovered"], - ["log-incoming-searches", "i", "Write information about incoming remote searches to a file"], ["testnet", "t", "Join the testnet"] ] @@ -119,10 +115,10 @@ def signal_handler(sig, _): config.set_http_api_enabled(True) config.set_http_api_port(options["restapi"]) - if options["dispersy"] > 0: - config.set_dispersy_port(options["dispersy"]) - elif options["dispersy"] == 0: - config.set_dispersy_enabled(False) + if options["ipv8"] > 0: + config.set_ipv8_port(options["ipv8"]) + elif options["ipv8"] == 0: + config.set_ipv8_enabled(False) if options["libtorrent"] != -1 and options["libtorrent"] > 0: config.set_libtorrent_port(options["libtorrent"]) @@ -137,18 +133,6 @@ def signal_handler(sig, _): self.session.start().addErrback(lambda failure: self.shutdown_process(failure.getErrorMessage())) msg("Tribler started") - if "auto-join-channel" in options and options["auto-join-channel"]: - msg("Enabling auto-joining of channels") - for community in self.session.get_dispersy_instance().get_communities(): - if isinstance(community, AllChannelCommunity): - community.auto_join_channel = True - - if "log-incoming-searches" in options and options["log-incoming-searches"]: - msg("Logging incoming remote searches") - for community in self.session.get_dispersy_instance().get_communities(): - if isinstance(community, SearchCommunity): - community.log_incoming_searches = self.log_incoming_remote_search - def makeService(self, options): """ Construct a Tribler service. diff --git a/twisted/plugins/tunnel_helper_plugin.py b/twisted/plugins/tunnel_helper_plugin.py index d4e7a7c8143..b8eeea0eb0f 100644 --- a/twisted/plugins/tunnel_helper_plugin.py +++ b/twisted/plugins/tunnel_helper_plugin.py @@ -1,29 +1,53 @@ """ This twistd plugin enables to start a tunnel helper headless using the twistd command. """ +from __future__ import absolute_import + import logging import os -import signal import re +import signal import time +from socket import inet_aton -from twisted.application.service import MultiService, IServiceMaker +from twisted.application.service import IServiceMaker, MultiService from twisted.conch import manhole_tap from twisted.internet import reactor from twisted.internet.task import LoopingCall from twisted.plugin import IPlugin from twisted.python import usage from twisted.python.log import msg + from zope.interface import implements -from socket import inet_aton from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Session import Session -from Tribler.Core.simpledefs import NTFY_TUNNEL, NTFY_REMOVE -from Tribler.dispersy.tool.clean_observers import clean_twisted_observers +from Tribler.Core.simpledefs import NTFY_REMOVE, NTFY_TUNNEL + +logger = logging.getLogger(__name__) + + +def clean_twisted_observers(publisher=None): + try: + from twisted.logger import LogPublisher, LimitedHistoryLogObserver, globalLogPublisher + if not publisher: + publisher = globalLogPublisher + except ImportError: + logger.debug("Running an older version of twisted, no need to clean the observers") + return + + logger.debug("Looking for rogue observers in %r", publisher._observers) + + for observer in publisher._observers: + if isinstance(observer, LogPublisher): + clean_twisted_observers(observer) + + elif isinstance(observer, LimitedHistoryLogObserver): + publisher.removeObserver(observer) + logger.debug("Removing observer %s", observer) -# Register yappi profiler -from Tribler.dispersy.utils import twistd_yappi + else: + logger.debug("Leaving alone observer %s", observer) def check_api_port(val): @@ -154,16 +178,11 @@ def start(self): config.set_tunnel_community_random_slots(self.options["random_slots"]) config.set_tunnel_community_competing_slots(self.options["competing_slots"]) config.set_torrent_checking_enabled(False) - config.set_megacache_enabled(False) - config.set_dispersy_enabled(False) config.set_ipv8_enabled(True) - config.set_torrent_collecting_enabled(False) config.set_libtorrent_enabled(False) config.set_video_server_enabled(False) - config.set_dispersy_port(ipv8_port) + config.set_ipv8_port(ipv8_port) config.set_ipv8_address(self.options["ipv8_address"]) - config.set_torrent_search_enabled(False) - config.set_channel_search_enabled(False) config.set_trustchain_enabled(True) config.set_credit_mining_enabled(False) config.set_market_community_enabled(False) From d14c6d501248185ef6c1df7e279751de8e31bcb5 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Thu, 10 Jan 2019 18:28:30 +0100 Subject: [PATCH 09/38] WIP - migration --- .../OrmBindings/channel_metadata.py | 4 +- .../MetadataStore/OrmBindings/metadata.py | 1 + Tribler/Core/Modules/MetadataStore/store.py | 10 ++- Tribler/Core/Upgrade/db71_to_pony.py | 81 +++++++++++++++---- Tribler/Core/statistics.py | 4 +- .../MetadataStore/test_channel_metadata.py | 6 ++ .../Core/Modules/MetadataStore/test_store.py | 28 ++++++- TriblerGUI/debug_window.py | 8 +- 8 files changed, 111 insertions(+), 31 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 875159e2112..5970c48aa7a 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -273,7 +273,7 @@ def deleted_contents(self): @property def dir_name(self): # Have to limit this to support Windows file path length limit - return str(self.public_key).encode('hex')[:CHANNEL_DIR_NAME_LENGTH] + return hexlify(self.public_key)[:CHANNEL_DIR_NAME_LENGTH] @property @db_session @@ -362,7 +362,7 @@ def extend_to_bitmask(txt): dirname_binmask_start = "x'" + extend_to_bitmask(dirname) + "'" - binmask_plus_one = "%X" % (int(dirname, 16) + 1) + binmask_plus_one = ("%X" % (int(dirname, 16) + 1)).zfill(len(dirname)) dirname_binmask_end = "x'" + extend_to_bitmask(binmask_plus_one) + "'" sql = "g.public_key >= " + dirname_binmask_start + " AND g.public_key < " + dirname_binmask_end diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index fc4dee821a7..665ac6df568 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -18,6 +18,7 @@ JUST_RECEIVED = 3 UPDATE_AVAILABLE = 4 PREVIEW_UPDATE_AVAILABLE = 5 +LEGACY_ENTRY = 6 PUBLIC_KEY_LEN = 64 diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 6f728803e9f..4c7c5ab2716 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -89,7 +89,7 @@ def __init__(self, db_filename, channels_dir, my_key): self._db = orm.Database() # Accessors for ORM-managed classes - #self.Author = author.define_binding(self._db) + # self.Author = author.define_binding(self._db) self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) @@ -242,3 +242,11 @@ def update_channel_info(self, payload): @db_session def get_my_channel(self): return self.ChannelMetadata.get_channel_with_id(self.my_key.pub().key_to_bin()[10:]) + + @db_session + def get_num_channels(self): + return orm.count(self.ChannelMetadata.select(lambda g: g.metadata_type == CHANNEL_TORRENT)) + + @db_session + def get_num_torrents(self): + return orm.count(self.TorrentMetadata.select(lambda g: g.metadata_type == REGULAR_TORRENT)) diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py index feaa02504e3..50e118cdf2f 100644 --- a/Tribler/Core/Upgrade/db71_to_pony.py +++ b/Tribler/Core/Upgrade/db71_to_pony.py @@ -1,15 +1,18 @@ -import os +from binascii import unhexlify -import lz4 import apsw -import lz4.frame +from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import LEGACY_ENTRY +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + +select_channels_sql = "Select name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ + + "FROM Channels " \ + + "WHERE nr_torrents >= 3 " \ + + "AND name not NULL;" + +select_torrents_sql = "SELECT dispersy_cid, infohash, timestamp" -lz4.frame -select_channels_sql = "Select name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam "\ - + "FROM Channels " \ - + "WHERE nr_torrents >= 3 " \ - + "AND " class DispersyToPonyMigration(object): @@ -23,17 +26,61 @@ def get_old_channels(self): cursor = connection.cursor() channels = [] - for name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute(select_channels_sql): - channels.append = {"title":name, - "public_key":dispersy_cid, - "timestamp":modified, - "version": nr_torrents, - "votes":nr_favorite, - "nr_spam) - + for channel_id, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute( + select_channels_sql): + channels.append({"old_id": channel_id, + "title": name, + "public_key": dispersy_cid, + "timestamp": modified, + "version": nr_torrents, + "votes": nr_favorite, + "xxx": nr_spam}) + return channels + def get_old_torrents(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + torrents = [] + for infohash, length, creation_date, name, dispersy_cid, torrent_id, category, num_seeders, num_leechers, tracker_url in cursor.execute( + select_channels_sql): + # num_seeders + # num_leechers + # last_tracker_check + torrents.append({ + "status": LEGACY_ENTRY, + "infohash": unhexlify(infohash), + "timestamp": 0, + "size": length, + "torrent_date": creation_date, + "title": name, + "tags": category, + "tracker_info": tracker_url, + "id_": torrent_id, + "origin_id": 0, + "public_key": dispersy_cid, + "signature": 0, + "xxx": int(category == u'xxx')}) + return torrents + if __name__ == "__main__": + my_key = default_eccrypto.generate_key(u"curve25519") + mds = MetadataStore(":memory:", "/tmp", my_key) + d = DispersyToPonyMigration("/tmp/tribler.sdb", "/tmp/dispersy.sdb", mds) + old_channels = d.get_old_channels() + """ + select Torrent.infohash, Torrent.length, Torrent.name, Torrent.creation_date, ChannelTorrents.torrent_id, Torrent.category +from ChannelTorrents, Channels, Torrent +where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id +select Torrent.infohash, Torrent.num_seeders, Torrent.num_leechers, Torrent.last_tracker_check +from ChannelTorrents, Channels, Torrent +where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id + + """ - def migrate_channels(self): + # 1 - Move Trackers (URLs) + # 2 - Move torrent Infohashes + # 3 - Move Infohash-Tracker relationships + # 4 - Move Metadata, based on Infohashes + # 5 - Move Channels diff --git a/Tribler/Core/statistics.py b/Tribler/Core/statistics.py index d14ae84f2d0..0e93feb120d 100644 --- a/Tribler/Core/statistics.py +++ b/Tribler/Core/statistics.py @@ -23,7 +23,9 @@ def get_tribler_statistics(self): Return a dictionary with some general Tribler statistics. """ db_size = os.path.getsize(self.session.lm.mds.db_filename) if self.session.lm.mds else 0 - stats_dict = {"db_size": db_size} + stats_dict = {"db_size": db_size, + "num_channels": self.session.lm.mds.get_num_channels(), + "num_torrents": self.session.lm.mds.get_num_torrents()} return stats_dict diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index 9d576851974..c5ceb8ea7a2 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import os +from binascii import hexlify, unhexlify from datetime import datetime from pony.orm import db_session @@ -160,6 +161,11 @@ def test_get_channel_with_dirname(self): channel_result = self.mds.ChannelMetadata.get_channel_with_dirname(dirname) self.assertEqual(channel_metadata, channel_result) + # Test for corner-case of channel PK starting with zeroes + channel_metadata.public_key = database_blob(unhexlify('0'*128)) + channel_result = self.mds.ChannelMetadata.get_channel_with_dirname(channel_metadata.dir_name) + self.assertEqual(channel_metadata, channel_result) + @db_session def test_get_channel_with_id(self): """ diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 17824da6e5e..5cfb3d40704 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -1,9 +1,11 @@ from __future__ import absolute_import import os +import random +import string +from binascii import unhexlify from pony.orm import db_session -from six.moves import xrange from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH @@ -75,13 +77,15 @@ def test_process_channel_dir_file(self): @db_session def test_squash_mdblobs(self): chunk_size = self.mds.ChannelMetadata._CHUNK_SIZE_LIMIT - md_list = [self.mds.TorrentMetadata(title='test' + str(x)) for x in xrange(0, 10)] + md_list = [self.mds.TorrentMetadata( + title=''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))) for _ in + range(0, 10)] chunk, _ = entries_to_chunk(md_list, chunk_size=chunk_size) self.assertItemsEqual(md_list, self.mds.process_compressed_mdblob(chunk)) # Test splitting into multiple chunks - chunk, index = entries_to_chunk(md_list, chunk_size=600) - chunk2, _ = entries_to_chunk(md_list, chunk_size=600, start_index=index) + chunk, index = entries_to_chunk(md_list, chunk_size=900) + chunk2, _ = entries_to_chunk(md_list, chunk_size=900, start_index=index) self.assertItemsEqual(md_list[:index], self.mds.process_compressed_mdblob(chunk)) self.assertItemsEqual(md_list[index:], self.mds.process_compressed_mdblob(chunk2)) @@ -117,3 +121,19 @@ def test_process_channel_dir(self): self.mds.process_channel_dir(self.CHANNEL_DIR, channel.public_key) self.assertEqual(len(channel.contents_list), 3) self.assertEqual(channel.local_version, 9) + + @db_session + def test_get_num_channels_torrents(self): + self.mds.ChannelMetadata(title='testchan', id_=0) + self.mds.ChannelMetadata(title='testchan', id_=123) + foreign1 = self.mds.ChannelMetadata(title='testchan', id_=0) + foreign1.public_key = unhexlify('1'*20) + foreign2 = self.mds.ChannelMetadata(title='testchan', id_=123) + foreign2.public_key = unhexlify('1'*20) + + md_list = [self.mds.TorrentMetadata(title='test' + str(x), status=NEW) for x in range(0, 3)] + + self.assertEqual(4, self.mds.get_num_channels()) + self.assertEqual(3, self.mds.get_num_torrents()) + + diff --git a/TriblerGUI/debug_window.py b/TriblerGUI/debug_window.py index b0367c0d3bc..d5bd1f907f2 100644 --- a/TriblerGUI/debug_window.py +++ b/TriblerGUI/debug_window.py @@ -275,13 +275,9 @@ def on_tribler_statistics(self, data): self.window().general_tree_widget.clear() self.create_and_add_widget_item("Tribler version", self.tribler_version, self.window().general_tree_widget) self.create_and_add_widget_item("Number of channels", data["num_channels"], self.window().general_tree_widget) - self.create_and_add_widget_item("Database size", format_size(data["database_size"]), + self.create_and_add_widget_item("Database size", format_size(data["db_size"]), self.window().general_tree_widget) - self.create_and_add_widget_item("Number of collected torrents", data["torrents"]["num_collected"], - self.window().general_tree_widget) - self.create_and_add_widget_item("Number of torrent files", data["torrents"]["num_files"], - self.window().general_tree_widget) - self.create_and_add_widget_item("Total size of torrent files", format_size(data["torrents"]["total_size"]), + self.create_and_add_widget_item("Number of known torrents", data["num_torrents"], self.window().general_tree_widget) self.create_and_add_widget_item("", "", self.window().general_tree_widget) From b9b85177f93442a88e1ce971eaccd99f07917376 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Sat, 12 Jan 2019 16:26:37 +0100 Subject: [PATCH 10/38] Modified REST API endpoints --- .../OrmBindings/channel_metadata.py | 3 +- .../MetadataStore/OrmBindings/metadata.py | 13 +- .../OrmBindings/torrent_metadata.py | 7 +- Tribler/Core/Modules/gigachannel_manager.py | 2 +- .../Core/Modules/restapi/metadata_endpoint.py | 36 ++-- .../Modules/restapi/mychannel_endpoint.py | 157 ++++++++++++++++-- Tribler/Core/Modules/restapi/rest_manager.py | 2 + .../Core/Modules/restapi/search_endpoint.py | 35 ++-- .../Modules/restapi/statistics_endpoint.py | 43 +++++ .../Core/TorrentChecker/torrent_checker.py | 13 +- Tribler/Core/Upgrade/db71_to_pony.py | 104 ++++++++---- Tribler/Core/Utilities/tracker_utils.py | 103 +++++++----- .../Modules/RestApi/test_metadata_endpoint.py | 22 ++- .../RestApi/test_statistics_endpoint.py | 7 + TriblerGUI/qt_resources/mainwindow.ui | 26 ++- .../qt_resources/torrent_details_container.ui | 121 ++------------ TriblerGUI/qt_resources/torrents_list.ui | 12 +- TriblerGUI/tribler_app.py | 3 +- TriblerGUI/utilities.py | 34 ++-- TriblerGUI/widgets/channelpage.py | 6 - TriblerGUI/widgets/editchannelpage.py | 31 ++-- TriblerGUI/widgets/homepage.py | 62 +++---- TriblerGUI/widgets/lazytableview.py | 11 +- TriblerGUI/widgets/searchresultspage.py | 7 +- TriblerGUI/widgets/tablecontentdelegate.py | 20 +-- TriblerGUI/widgets/tablecontentmodel.py | 75 +-------- TriblerGUI/widgets/torrentdetailstabwidget.py | 144 +++++++++------- TriblerGUI/widgets/torrentslistwidget.py | 16 -- TriblerGUI/widgets/triblertablecontrollers.py | 35 +++- logger.conf | 2 +- run_tribler.py | 10 +- 31 files changed, 674 insertions(+), 488 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 5970c48aa7a..2998f6e2b96 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -405,8 +405,7 @@ def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - pony_query = ChannelMetadata.get_entries_query( - ChannelMetadata, sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + pony_query = ChannelMetadata.get_entries_query(sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) # Filter subscribed/non-subscribed if subscribed: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py index 665ac6df568..c4c1cf7f274 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py @@ -66,6 +66,11 @@ def generate_dict_from_pony_args(cls, **kwargs): private_key_override = kwargs["sign_with"] kwargs.pop("sign_with") + skip_key_check = False + if "skip_key_check" in kwargs and kwargs["skip_key_check"]: + skip_key_check = True + kwargs.pop("skip_key_check") + # FIXME: potential race condition here? To avoid it, generate the signature _before_ calling "super" super(Metadata, self).__init__(*args, **kwargs) @@ -82,6 +87,8 @@ def generate_dict_from_pony_args(cls, **kwargs): # Key/signature given, check them for correctness elif ("public_key" in kwargs) and ("signature" in kwargs) and self.has_valid_signature(): return + elif skip_key_check: # For getting legacy/test stuff + return # Otherwise, something is wrong raise InvalidSignatureException( @@ -151,7 +158,7 @@ def from_dict(cls, dct): @classmethod @db_session - def get_entries_query(cls, metadata_type, sort_by=None, sort_asc=True, query_filter=None): + def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): """ Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. @@ -159,11 +166,11 @@ def get_entries_query(cls, metadata_type, sort_by=None, sort_asc=True, query_fil the total number of results, regardless the passed first/last parameter. """ # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! - pony_query = select(g for g in metadata_type) + pony_query = select(g for g in cls) # Filter the results on a keyword or some keywords if query_filter: - pony_query = metadata_type.search_keyword(query_filter + "*", lim=1000) + pony_query = cls.search_keyword(query_filter + "*", lim=1000) # Sort the query if sort_by: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 7d2b2a63822..4d50f80e263 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -94,7 +94,7 @@ def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte the total number of results, regardless the passed first/last parameter. """ pony_query = TorrentMetadata.get_entries_query( - TorrentMetadata, sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) # We only want torrents, not channel torrents pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) @@ -108,7 +108,7 @@ def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte return pony_query[first - 1:last], total_results @db_session - def to_simple_dict(self, include_status=False): + def to_simple_dict(self, include_status=False, include_trackers=False): """ Return a basic dictionary with information about the channel. """ @@ -126,6 +126,9 @@ def to_simple_dict(self, include_status=False): if include_status: simple_dict['status'] = self.status + if include_trackers: + simple_dict['trackers'] = [tracker.url for tracker in self.health.trackers] + return simple_dict return TorrentMetadata diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index dfbda891fd2..4151ebf85ea 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -47,7 +47,7 @@ def check_channels_updates(self): if not self.session.has_download(hexlify(str(channel.infohash))): self._logger.info("Downloading new channel version %s ver %i->%i", str(channel.public_key).encode("hex"), - channel.local_version, channel.version) + channel.local_version, channel.timestamp) self.download_channel(channel) def on_channel_download_finished(self, download, channel_id, finished_deferred=None): diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 4805fcb1c5d..d207eb54f0d 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -214,10 +214,23 @@ class SpecificTorrentEndpoint(resource.Resource): def __init__(self, session, infohash): resource.Resource.__init__(self) self.session = session - self.infohash = infohash + self.infohash = unhexlify(infohash) self.putChild("health", TorrentHealthEndpoint(self.session, self.infohash)) + @db_session + def render_GET(self, request): + md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: + g.infohash == database_blob(self.infohash))) + if not md_list: + request.setResponseCode(http.NOT_FOUND) + request.write(json.dumps({"error": "torrent not found in database"})) + return + + torrent = md_list[0] + + return json.dumps({"torrent": torrent.to_simple_dict(include_trackers=True)}) + class TorrentsRandomEndpoint(BaseTorrentsEndpoint): @@ -244,7 +257,7 @@ class TorrentHealthEndpoint(resource.Resource): def __init__(self, session, infohash): resource.Resource.__init__(self) self.session = session - self.infohash = unhexlify(infohash) + self.infohash = infohash self._logger = logging.getLogger(self.__class__.__name__) def finish_request(self, request): @@ -266,20 +279,23 @@ def render_GET(self, request): .. sourcecode:: none - curl http://localhost:8085/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779/health?timeout=15&refresh=1 + curl http://localhost:8085/metadata/torrents/97d2d8f5d37e56cfaeaae151d55f05b077074779/health + ?timeout=15&refresh=1 **Example response**: .. sourcecode:: javascript { - "http://mytracker.com:80/announce": [{ - "seeders": 43, - "leechers": 20, - "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779" - }], - "http://nonexistingtracker.com:80/announce": { - "error": "timeout" + "health": { + "http://mytracker.com:80/announce": { + "seeders": 43, + "leechers": 20, + "infohash": "97d2d8f5d37e56cfaeaae151d55f05b077074779" + }, + "http://nonexistingtracker.com:80/announce": { + "error": "timeout" + } } } diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index 4f494d996e4..a179d61f4ac 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -1,13 +1,23 @@ from __future__ import absolute_import +import base64 import json import os +import sys from binascii import unhexlify, hexlify from pony.orm import db_session from twisted.web import resource, http from Tribler.Core.Modules.restapi.metadata_endpoint import SpecificChannelTorrentsEndpoint +from Tribler.Core.TorrentDef import TorrentDef +from Tribler.Core.exceptions import DuplicateTorrentFileError, HttpError + + +def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i:i + n] class BaseMyChannelEndpoint(resource.Resource): @@ -153,6 +163,129 @@ def render_DELETE(self, request): return json.dumps({"success": True}) + @db_session + def render_PUT(self, request): + """ + .. http:put:: /mychannel/torrents + + Add a torrent file to your own channel. Returns error 500 if something is wrong with the torrent file + and DuplicateTorrentFileError if already added to your channel. The torrent data is passed as base-64 encoded + string. The description is optional. + + Option torrents_dir adds all .torrent files from a chosen directory + Option recursive enables recursive scanning of the chosen directory for .torrent files + + **Example request**: + + .. sourcecode:: none + + curl -X PUT http://localhost:8085/mychannel/torrents + --data "torrent=...&description=funny video" + + **Example response**: + + .. sourcecode:: javascript + + { + "added": True + } + + **Example request**: + + .. sourcecode:: none + + curl -X PUT http://localhost:8085/mychannel/torrents? --data "torrents_dir=some_dir&recursive=1" + + **Example response**: + + .. sourcecode:: javascript + + { + "added": 13 + } + + :statuscode 404: if your channel does not exist. + :statuscode 500: if the passed torrent data is corrupt. + """ + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created yet"}) + + parameters = http.parse_qs(request.content.read(), 1) + + torrents_dir = None + if 'torrents_dir' in parameters and parameters['torrents_dir'] > 0: + torrents_dir = parameters['torrents_dir'][0] + if not os.path.isabs(torrents_dir): + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "the torrents_dir should point to a directory"}) + + recursive = False + if 'recursive' in parameters and parameters['recursive'] > 0: + recursive = parameters['recursive'][0] + if not torrents_dir: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"the torrents_dir parameter should be provided when the rec"}) + + if torrents_dir: + torrents_list = [] + errors_list = [] + + if recursive: + def rec_gen(): + for root, _, filenames in os.walk(torrents_dir): + for fn in filenames: + yield os.path.join(root, fn) + + filename_generator = rec_gen() + else: + filename_generator = os.listdir(torrents_dir) + + # Build list of .torrents to process + for f in filename_generator: + filepath = os.path.join(torrents_dir, f) + filename = str(filepath) if sys.platform == 'win32' else filepath.decode('utf-8') + if os.path.isfile(filepath) and filename.endswith(u'.torrent'): + torrents_list.append(filepath) + + for chunk in chunks(torrents_list, 100): # 100 is a reasonable chunk size for commits + with db_session: + for f in chunk: + try: + my_channel.add_torrent_to_channel(TorrentDef.load(f), {}) + except DuplicateTorrentFileError: + pass + except: + errors_list.append(f) + + return json.dumps({"added": len(torrents_list), "errors": errors_list}) + + if 'torrent' not in parameters or len(parameters['torrent']) == 0: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "torrent parameter missing"}) + + if 'description' not in parameters or len(parameters['description']) == 0: + extra_info = {} + else: + extra_info = {'description': parameters['description'][0]} + + # Try to parse the torrent data + try: + torrent = base64.b64decode(parameters['torrent'][0]) + torrent_def = TorrentDef.load_from_memory(torrent) + except ValueError: + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "invalid torrent file"}) + + try: + my_channel.add_torrent_to_channel(torrent_def, extra_info) + except DuplicateTorrentFileError: + request.setResponseCode(http.INTERNAL_SERVER_ERROR) + return json.dumps({"error": "this torrent already exists in your channel"}) + + return json.dumps({"added": 1}) + class MyChannelSpecificTorrentEndpoint(BaseMyChannelEndpoint): @@ -160,27 +293,27 @@ def __init__(self, session, infohash): BaseMyChannelEndpoint.__init__(self, session) self.infohash = unhexlify(infohash) + @db_session def render_PATCH(self, request): parameters = http.parse_qs(request.content.read(), 1) if 'status' not in parameters: request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "status parameter missing"}) - with db_session: - my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() - if not my_channel: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "your channel has not been created"}) + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if not my_channel: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "your channel has not been created"}) - torrent = my_channel.get_torrent(self.infohash) - if not torrent: - request.setResponseCode(http.NOT_FOUND) - return json.dumps({"error": "torrent with the specified infohash could not be found"}) + torrent = my_channel.get_torrent(self.infohash) + if not torrent: + request.setResponseCode(http.NOT_FOUND) + return json.dumps({"error": "torrent with the specified infohash could not be found"}) - new_status = int(parameters['status'][0]) - torrent.status = new_status + new_status = int(parameters['status'][0]) + torrent.status = new_status - return json.dumps({"success": True, "new_status": new_status}) + return json.dumps({"success": True, "new_status": new_status, "dirty": my_channel.dirty}) class MyChannelCommitEndpoint(BaseMyChannelEndpoint): diff --git a/Tribler/Core/Modules/restapi/rest_manager.py b/Tribler/Core/Modules/restapi/rest_manager.py index 7cf9ed9fef2..7c251de8f96 100644 --- a/Tribler/Core/Modules/restapi/rest_manager.py +++ b/Tribler/Core/Modules/restapi/rest_manager.py @@ -48,6 +48,8 @@ def start(self): except CannotListenError: bind_attempts += 1 + self._logger.info("Starting REST API on port %d", self.site.port) + # REST Manager does not accept any new requests if Tribler is shutting down. # Note that environment variable 'TRIBLER_SHUTTING_DOWN' is set to 'TRUE' (string) # when shutdown has started. Also see RESTRequest.process() method below. diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 589dea73ede..4c82eb40b1f 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -2,11 +2,12 @@ import logging +from pony import orm from pony.orm import db_session - from twisted.web import http, resource import Tribler.Core.Utilities.json_util as json +from Tribler.Core.Modules.MetadataStore.serialization import REGULAR_TORRENT, CHANNEL_TORRENT from Tribler.util import cast_to_unicode_utf8 @@ -70,7 +71,6 @@ def render_GET(self, request): sort_by option sorts results in forward or backward, based on column name (e.g. "id" vs "-id") txt option uses FTS search on the chosen word* terms type option limits query to certain metadata types (e.g. "torrent" or "channel") - subscribed option limits query to channels you are subscribed for **Example request**: @@ -111,38 +111,27 @@ def render_GET(self, request): first, last, sort_by, sort_asc, data_type = SearchEndpoint.sanitize_parameters(request.args) query = request.args['q'][0] - torrent_results, total_torrents = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter=query) - torrents_json = [] - for torrent in torrent_results: - torrent_json = torrent.to_simple_dict() - torrent_json['type'] = 'torrent' - torrents_json.append(torrent_json) - - channel_results, total_channels = self.session.lm.mds.ChannelMetadata.get_channels( - first, last, sort_by, sort_asc, query_filter=query) - channels_json = [] - for channel in channel_results: - channel_json = channel.to_simple_dict() - channel_json['type'] = 'channel' - channels_json.append(channel_json) - if not data_type: - search_results = channels_json + torrents_json + search_scope = lambda g: (g.metadata_type == REGULAR_TORRENT or g.metadata_type == CHANNEL_TORRENT) elif data_type == 'channel': - search_results = channels_json + search_scope = lambda g: g.metadata_type == CHANNEL_TORRENT elif data_type == 'torrent': - search_results = torrents_json + search_scope = lambda g: g.metadata_type == REGULAR_TORRENT else: - search_results = [] + return json.dumps({"error": "Trying to query for unknown type of metadata"}) + + results = self.session.lm.mds.TorrentMetadata.get_entries_query( + sort_by, sort_asc, query_filter=query).where(search_scope) + search_results = [(dict(type={REGULAR_TORRENT: 'torrent', CHANNEL_TORRENT: 'channel'}[r.metadata_type], + **(r.to_simple_dict()))) for r in results] return json.dumps({ "results": search_results[first - 1:last], "first": first, "last": last, "sort_by": sort_by, "sort_asc": sort_asc, - "total": total_torrents + total_channels + "total": orm.count(results) }) diff --git a/Tribler/Core/Modules/restapi/statistics_endpoint.py b/Tribler/Core/Modules/restapi/statistics_endpoint.py index c822eb1e065..50688f1e561 100644 --- a/Tribler/Core/Modules/restapi/statistics_endpoint.py +++ b/Tribler/Core/Modules/restapi/statistics_endpoint.py @@ -14,6 +14,7 @@ def __init__(self, session): child_handler_dict = { "tribler": StatisticsTriblerEndpoint, "ipv8": StatisticsIPv8Endpoint, + "communities": StatisticsCommunitiesEndpoint } for path, child_cls in child_handler_dict.iteritems(): @@ -98,3 +99,45 @@ def render_GET(self, request): return json.dumps({ 'ipv8_statistics': self.session.get_ipv8_statistics() }) + + +class StatisticsCommunitiesEndpoint(resource.Resource): + """ + This class handles requests regarding IPv8 communities statistics. + """ + + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + + def render_GET(self, request): + """ + .. http:get:: /statistics/communities + + A GET request to this endpoint returns general statistics of active Dispersy communities. + + **Example request**: + + .. sourcecode:: none + + curl -X GET http://localhost:8085/statistics/communities + + **Example response**: + + .. sourcecode:: javascript + + { + "ipv8_overlay_statistics": [{ + "master_peer": "48d04e922dec4430daf22400c9d4cc5a3a53b27d", + "my_peer": "a66ebac9d88a239ef348a030d5ed3837868fc06d", + "peers": 43, + "global_time": 42, + "overlay_name", "ChannelCommunity", + "packets_sent": 43, + "statistics": { ... }, + }, { ... }] + } + """ + return json.dumps({ + 'ipv8_overlay_statistics': self.session.get_ipv8_overlay_statistics() + }) diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index 9534f245c1b..9d6adcda5f9 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -121,9 +121,8 @@ def _task_select_tracker(self): # get the torrents that should be checked infohashes = [] with db_session: - tracker = list(self.tribler_session.lm.mds.TrackerState.select(lambda g: str(g.url) == tracker_url)) + tracker = self.tribler_session.lm.mds.TrackerState[tracker_url] if tracker: - tracker = tracker[0] torrents = tracker.torrents for torrent in torrents: dynamic_interval = self._torrent_check_retry_interval * (2 ** tracker.failures) @@ -175,8 +174,7 @@ def update_tracker_info(self, tracker_url, value): @db_session def get_valid_trackers_of_torrent(self, torrent_id): """ Get a set of valid trackers for torrent. Also remove any invalid torrent.""" - db_tracker_list = list(self.tribler_session.lm.mds.TorrentState.select( - lambda g: g.infohash == database_blob(torrent_id)))[0].trackers + db_tracker_list = self.tribler_session.lm.mds.TorrentState[database_blob(torrent_id)].trackers return set([str(tracker.url) for tracker in db_tracker_list if is_valid_url(str(tracker.url)) or str(tracker.url) == u'DHT']) @@ -214,12 +212,10 @@ def add_gui_request(self, infohash, timeout=20, scrape_now=False): :param scrape_now: Flag whether we want to force scraping immediately """ with db_session: - result = list(self.tribler_session.lm.mds.TorrentState.select( - lambda g: g.infohash == database_blob(infohash))) + result = self.tribler_session.lm.mds.TorrentState[database_blob(infohash)] if not result: self._logger.warn(u"torrent info not found, skip. infohash: %s", hexlify(infohash)) return fail(Failure(RuntimeError("Torrent not found"))) - result = result[0] torrent_id = str(result.infohash) last_check = result.last_check @@ -311,8 +307,7 @@ def _update_torrent_result(self, response, update_dict): self._logger.debug(u"Update result %s/%s for %s", seeders, leechers, hexlify(infohash)) with db_session: - result = list(self.tribler_session.lm.mds.TorrentState.select( - lambda g: g.infohash == database_blob(infohash)))[0] + result = self.tribler_session.lm.mds.TorrentState[database_blob(infohash)] for tracker in result.trackers: tracker.last_check = int(time.time()) if update_dict.get(tracker.url, {'seeders': 0, 'leechers': 0}) > 0: diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py index 50e118cdf2f..fe7d02a6d2d 100644 --- a/Tribler/Core/Upgrade/db71_to_pony.py +++ b/Tribler/Core/Upgrade/db71_to_pony.py @@ -1,9 +1,15 @@ +import base64 +import datetime from binascii import unhexlify import apsw +from pony.orm import db_session +from six import text_type from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url +from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto select_channels_sql = "Select name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ @@ -11,8 +17,6 @@ + "WHERE nr_torrents >= 3 " \ + "AND name not NULL;" -select_torrents_sql = "SELECT dispersy_cid, infohash, timestamp" - class DispersyToPonyMigration(object): @@ -37,50 +41,94 @@ def get_old_channels(self): "xxx": nr_spam}) return channels - def get_old_torrents(self): + select_trackers_sql = "select tracker_id, tracker, last_check, failures, is_alive from TrackerInfo" + + def get_old_trackers(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + + trackers = {} + for tracker_id, tracker, last_check, failures, is_alive in cursor.execute(self.select_trackers_sql): + try: + tracker_url_sanitized = get_uniformed_tracker_url(tracker) + if not tracker_url_sanitized: + continue + except: + # Skip malformed trackers + continue + trackers[tracker_id] = ({"tracker": tracker_url_sanitized, + "last_check": last_check, + "failures": failures, + "is_alive": is_alive}) + return trackers + + select_torrents_sql = "SELECT ct.channel_id, tracker_id, ct.name, t.infohash, t.length, t.creation_date, t.torrent_id, t.category, t.num_seeders, t.num_leechers, t.last_tracker_check " \ + " FROM _ChannelTorrents ct, Torrent t, TorrentTrackerMapping mp WHERE ct.name NOT NULL and t.length>0 AND t.category NOT NULL AND ct.deleted_at IS NULL " \ + " AND t.torrent_id == ct.torrent_id AND mp.torrent_id == t.torrent_id " + + def get_old_torrents(self, trackers, chunk_size=10000, offset=0): connection = apsw.Connection(self.tribler_db) cursor = connection.cursor() torrents = [] - for infohash, length, creation_date, name, dispersy_cid, torrent_id, category, num_seeders, num_leechers, tracker_url in cursor.execute( - select_channels_sql): + for channel_id, tracker_id, name, infohash, length, creation_date, torrent_id, category, num_seeders, num_leechers, tracker_url in cursor.execute( + self.select_torrents_sql + " LIMIT " + str(chunk_size) + " OFFSET " + str(offset)): + # check if name is valid unicode data + try: + name = text_type(name) + except UnicodeDecodeError: + continue # num_seeders # num_leechers # last_tracker_check + try: + if len(base64.decodestring(infohash)) != 20: + continue + except: + continue + infohash = base64.decodestring(infohash) + torrents.append({ "status": LEGACY_ENTRY, - "infohash": unhexlify(infohash), - "timestamp": 0, + "infohash": infohash, + "timestamp": torrent_id, "size": length, - "torrent_date": creation_date, + "torrent_date": datetime.datetime.utcfromtimestamp(creation_date), "title": name, "tags": category, - "tracker_info": tracker_url, "id_": torrent_id, "origin_id": 0, - "public_key": dispersy_cid, - "signature": 0, - "xxx": int(category == u'xxx')}) - return torrents - - if __name__ == "__main__": - my_key = default_eccrypto.generate_key(u"curve25519") - mds = MetadataStore(":memory:", "/tmp", my_key) - d = DispersyToPonyMigration("/tmp/tribler.sdb", "/tmp/dispersy.sdb", mds) - old_channels = d.get_old_channels() - """ - select Torrent.infohash, Torrent.length, Torrent.name, Torrent.creation_date, ChannelTorrents.torrent_id, Torrent.category + "tracker_info": trackers[tracker_id]['tracker'] if tracker_id in trackers else "", + "public_key": database_blob(unhexlify(("%X" % channel_id).zfill(128))), + "signature": database_blob('\x00' * 32), + "xxx": int(category == u'xxx'), + "skip_key_check": True}) + + return torrents + + +if __name__ == "__main__": + my_key = default_eccrypto.generate_key(u"curve25519") + mds = MetadataStore(":memory:", "/tmp", my_key) + d = DispersyToPonyMigration("/tmp/tribler.sdb", "/tmp/dispersy.sdb", mds) + # old_channels = d.get_old_channels() + old_trackers = d.get_old_trackers() + with db_session: + for t in d.get_old_torrents(old_trackers): + mds.TorrentMetadata(**t) + +""" +select Torrent.infohash, Torrent.length, Torrent.name, Torrent.creation_date, ChannelTorrents.torrent_id, Torrent.category from ChannelTorrents, Channels, Torrent where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id select Torrent.infohash, Torrent.num_seeders, Torrent.num_leechers, Torrent.last_tracker_check from ChannelTorrents, Channels, Torrent where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - - """ +""" - # 1 - Move Trackers (URLs) - # 2 - Move torrent Infohashes - # 3 - Move Infohash-Tracker relationships - # 4 - Move Metadata, based on Infohashes - # 5 - Move Channels +# 1 - Move Trackers (URLs) +# 2 - Move torrent Infohashes +# 3 - Move Infohash-Tracker relationships +# 4 - Move Metadata, based on Infohashes +# 5 - Move Channels diff --git a/Tribler/Core/Utilities/tracker_utils.py b/Tribler/Core/Utilities/tracker_utils.py index 4f0e8ddf535..0bfe81fa487 100644 --- a/Tribler/Core/Utilities/tracker_utils.py +++ b/Tribler/Core/Utilities/tracker_utils.py @@ -1,5 +1,7 @@ from __future__ import absolute_import +import re + from six import string_types, text_type from six.moves.http_client import HTTP_PORT from six.moves.urllib.parse import urlparse @@ -9,6 +11,21 @@ class MalformedTrackerURLException(Exception): pass +delimiters_regex = re.compile(r'[\r\n\x00\s\t;]*(%20)*') + + +url_regex = re.compile( + r'^(?:http|udp|wss)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... + r'localhost|' # localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + +remove_trailing_junk = re.compile(r'[,*.:]+\Z') +truncated_url_detector = re.compile(r'\.\.\.') + + def get_uniformed_tracker_url(tracker_url): """ Parse a tracker url of string_types type. @@ -37,45 +54,55 @@ def get_uniformed_tracker_url(tracker_url): except UnicodeDecodeError: return None - url = urlparse(tracker_url) - - # accessing urlparse attributes may throw UnicodeError's or ValueError's - try: - # scheme must be either UDP or HTTP - if url.scheme == 'udp' or url.scheme == 'http': - uniformed_scheme = url.scheme - else: - return None - - uniformed_hostname = url.hostname - - if not url.port: - # UDP trackers must have a port + # Search the string for delimiters and try to get the first correct URL + for tracker_url in re.split(delimiters_regex, tracker_url): + # Rule out truncated URLs + if re.search(truncated_url_detector, tracker_url): + continue + # Try to match it against a simple regexp + if not re.match(url_regex, tracker_url): + continue + + tracker_url = re.sub(remove_trailing_junk, '', tracker_url) + url = urlparse(tracker_url) + + # accessing urlparse attributes may throw UnicodeError's or ValueError's + try: + # scheme must be either UDP or HTTP + if url.scheme == 'udp' or url.scheme == 'http': + uniformed_scheme = url.scheme + else: + continue + + uniformed_hostname = url.hostname + + if not url.port: + # UDP trackers must have a port + if url.scheme == 'udp': + continue + # HTTP trackers default to port HTTP_PORT + elif url.scheme == 'http': + uniformed_port = HTTP_PORT + else: + uniformed_port = url.port + + # UDP trackers have no path if url.scheme == 'udp': - return None - # HTTP trackers default to port HTTP_PORT - elif url.scheme == 'http': - uniformed_port = HTTP_PORT - else: - uniformed_port = url.port - - # UDP trackers have no path - if url.scheme == 'udp': - uniformed_path = '' - else: - uniformed_path = url.path.rstrip('/') - # HTTP trackers must have a path - if url.scheme == 'http' and not url.path: - return None - - if url.scheme == 'http' and uniformed_port == HTTP_PORT: - uniformed_url = u'%s://%s%s' % (uniformed_scheme, uniformed_hostname, uniformed_path) - else: - uniformed_url = u'%s://%s:%d%s' % (uniformed_scheme, uniformed_hostname, uniformed_port, uniformed_path) - except (UnicodeError, ValueError): - return None - - return uniformed_url + uniformed_path = '' + else: + uniformed_path = url.path.rstrip('/') + # HTTP trackers must have a path + if url.scheme == 'http' and not url.path: + continue + + if url.scheme == 'http' and uniformed_port == HTTP_PORT: + uniformed_url = u'%s://%s%s' % (uniformed_scheme, uniformed_hostname, uniformed_path) + else: + uniformed_url = u'%s://%s:%d%s' % (uniformed_scheme, uniformed_hostname, uniformed_port, uniformed_path) + except (UnicodeError, ValueError): + continue + return uniformed_url + return None def parse_tracker_url(tracker_url): diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index a85dd73b76c..d1cb1e8d65e 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -24,6 +24,7 @@ class BaseTestMetadataEndpoint(AbstractApiTest): @inlineCallbacks def setUp(self): yield super(BaseTestMetadataEndpoint, self).setUp() + self.infohashes = [] # Add a few channels with db_session: @@ -31,7 +32,9 @@ def setUp(self): self.session.lm.mds.Metadata._my_key = default_eccrypto.generate_key('curve25519') _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) for torrent_ind in xrange(5): - _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=random_infohash()) + rand_infohash = random_infohash() + self.infohashes.append(rand_infohash) + _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=rand_infohash) def setUpPreSession(self): super(BaseTestMetadataEndpoint, self).setUpPreSession() @@ -139,6 +142,23 @@ def on_response(response): return self.do_request('metadata/channels/popular?limit=5', expected_code=200).addCallback(on_response) +class TestSpecificTorrentEndpoint(BaseTestMetadataEndpoint): + + def test_get_info_torrent_not_exist(self): + """ + Test if an error is returned when querying information of a torrent that does not exist + """ + self.should_check_equality = False + return self.do_request('metadata/torrents/aabbcc', expected_code=404) + + def test_get_info_torrent(self): + """ + Test whether we can successfully query information about a torrent with the REST API + """ + self.should_check_equality = False + return self.do_request('metadata/torrents/%s' % hexlify(self.infohashes[0]), expected_code=200) + + class TestRandomTorrentsEndpoint(BaseTestMetadataEndpoint): def test_get_random_torrents_neg_limit(self): diff --git a/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py index b10e9a9b030..fe7c2e348eb 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_statistics_endpoint.py @@ -1,8 +1,12 @@ +import os + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Test.tools import trial_timeout from twisted.internet.defer import inlineCallbacks import Tribler.Core.Utilities.json_util as json from Tribler.pyipv8.ipv8.attestation.trustchain.community import TrustChainCommunity +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto from Tribler.pyipv8.ipv8.test.mocking.ipv8 import MockIPv8 from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest @@ -21,9 +25,12 @@ def setUp(self): self.mock_ipv8.endpoint.bytes_down = 20 self.session.lm.ipv8 = self.mock_ipv8 self.session.config.set_ipv8_enabled(True) + my_key = default_eccrypto.generate_key(u"curve25519") + self.session.lm.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, my_key) @inlineCallbacks def tearDown(self): + self.session.lm.mds.shutdown() self.session.lm.ipv8 = None yield self.mock_ipv8.unload() yield super(TestStatisticsEndpoint, self).tearDown() diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 76073dc793f..8906a8cc7f3 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -1090,7 +1090,7 @@ background-color: #e67300; - 2 + 1 @@ -1243,6 +1243,25 @@ margin: 10px; + + + + + 0 + 0 + + + + color: white; font-size: 16px; + + + No recommended torrents found. + + + Qt::AlignCenter + + + @@ -1258,6 +1277,11 @@ padding-left: 10px; QTableWidget::item { padding-right: 10px; padding-bottom: 10px; +border: none; +} + +QTableWidget::item::hover { +background: transparent; } diff --git a/TriblerGUI/qt_resources/torrent_details_container.ui b/TriblerGUI/qt_resources/torrent_details_container.ui index 8d67de876da..24a2e899c88 100644 --- a/TriblerGUI/qt_resources/torrent_details_container.ui +++ b/TriblerGUI/qt_resources/torrent_details_container.ui @@ -173,6 +173,13 @@ QTabBar::tab:selected { + + + + + + + @@ -183,13 +190,6 @@ QTabBar::tab:selected { - - - - - - - @@ -197,16 +197,6 @@ QTabBar::tab:selected { - - - - font-weight: bold; margin-top:5px - - - Health - - - @@ -247,99 +237,22 @@ QTabBar::tab:selected { + + + + font-weight: bold; + + + Health + + + - - - Files - - - - 0 - - - 0 - - - 0 - - - 0 - - - 0 - - - - - QTreeWidget { -border: none; -font-size: 13px; -} -QTreeWidget::item { -color: white; -border-bottom: 1px solid #303030; -} -QTreeWidget::item:hover { -background-color: #303030; -} -QTreeWidget::item::selected { -background-color: #444; -} -QHeaderView { -background-color: transparent; -} -QHeaderView::section { -background-color: transparent; -border: none; -color: #B5B5B5; -padding: 10px; -font-size: 14px; -border-bottom: 1px solid #303030; -} -QHeaderView::drop-down { -color: red; -} -QHeaderView::section:hover { -color: white; -} -QTableCornerButton::section { -background-color: transparent; -} - - - QAbstractItemView::NoSelection - - - 0 - - - 300 - - - false - - - true - - - - PATH - - - - - SIZE - - - - - - Trackers diff --git a/TriblerGUI/qt_resources/torrents_list.ui b/TriblerGUI/qt_resources/torrents_list.ui index cd24dba076a..a08c129326e 100644 --- a/TriblerGUI/qt_resources/torrents_list.ui +++ b/TriblerGUI/qt_resources/torrents_list.ui @@ -118,7 +118,7 @@ QTableView::item::hover { Qt::ScrollBarAlwaysOff
- QAbstractItemView::ExtendedSelection + QAbstractItemView::SingleSelection QAbstractItemView::SelectRows @@ -164,17 +164,17 @@ QTableView::item::hover { - - TorrentsTableView - QTableView -
TriblerGUI.widgets.lazytableview.h
-
TorrentDetailsContainer QWidget
TriblerGUI.widgets.torrentdetailscontainer.h
1
+ + TorrentsTableView + QTableView +
TriblerGUI.widgets.lazytableview.h
+
diff --git a/TriblerGUI/tribler_app.py b/TriblerGUI/tribler_app.py index 1901f8e928c..c77cfae5f66 100644 --- a/TriblerGUI/tribler_app.py +++ b/TriblerGUI/tribler_app.py @@ -2,7 +2,6 @@ import sys from PyQt5.QtCore import QEvent -from PyQt5.QtWidgets import QApplication from TriblerGUI.code_executor import CodeExecutor from TriblerGUI.single_application import QtSingleApplication @@ -13,7 +12,7 @@ class TriblerApplication(QtSingleApplication): This class represents the main Tribler application. """ def __init__(self, app_name, args): - QApplication.__init__(self, args) + QtSingleApplication.__init__(self, app_name, args) self.code_executor = None self.messageReceived.connect(self.on_app_message) diff --git a/TriblerGUI/utilities.py b/TriblerGUI/utilities.py index b4c445f82c6..635180fc570 100644 --- a/TriblerGUI/utilities.py +++ b/TriblerGUI/utilities.py @@ -6,7 +6,7 @@ from urllib import quote_plus import TriblerGUI -from TriblerGUI.defs import VIDEO_EXTS +from TriblerGUI.defs import VIDEO_EXTS, HEALTH_GOOD, HEALTH_UNCHECKED, HEALTH_MOOT, HEALTH_DEAD def index2uri(index): @@ -181,27 +181,6 @@ def get_image_path(filename): return os.path.join(get_base_path(), 'images/%s' % filename) -def bisect_right(item, item_list, is_torrent): - """ - This method inserts a channel/torrent in a sorted list. The sorting is based on relevance score. - The implementation is based on bisect_right. - """ - lo = 0 - hi = len(item_list) - while lo < hi: - mid = (lo+hi) // 2 - if item['relevance_score'] == item_list[mid]['relevance_score'] and is_torrent: - if len(split_into_keywords(item['name'])) < len(split_into_keywords(item_list[mid]['name'])): - hi = mid - else: - lo = mid + 1 - elif item['relevance_score'] > item_list[mid]['relevance_score']: - hi = mid - else: - lo = mid + 1 - return lo - - def get_gui_setting(gui_settings, value, default, is_bool=False): """ Utility method to get a specific GUI setting. The is_bool flag defines whether we expect a boolean so we convert it @@ -260,3 +239,14 @@ def prec_div(number, precision): Divide a given number by 10^precision. """ return float(number) / float(10 ** precision) + + +def get_health(seeders, leechers, last_tracker_check): + if last_tracker_check == 0: + return HEALTH_UNCHECKED + if seeders > 0: + return HEALTH_GOOD + elif leechers > 0: + return HEALTH_MOOT + else: + return HEALTH_DEAD diff --git a/TriblerGUI/widgets/channelpage.py b/TriblerGUI/widgets/channelpage.py index 71fa5ee81f9..6fd5fb29acc 100644 --- a/TriblerGUI/widgets/channelpage.py +++ b/TriblerGUI/widgets/channelpage.py @@ -40,15 +40,9 @@ def initialize_with_channel(self, channel_info): self.window().subscription_widget.initialize_with_channel(channel_info) self.window().channel_page_container.details_container.hide() - self.window().channel_page_container.content_table.on_torrent_clicked.connect(self.on_torrent_clicked) - self.model.channel_pk = channel_info['public_key'] self.load_torrents() - def on_torrent_clicked(self, torrent_info): - self.window().channel_page_container.details_container.show() - self.window().channel_page_container.details_tab_widget.update_with_torrent(torrent_info) - def load_torrents(self): self.controller.model.reset() self.controller.load_torrents(1, 50) # Load the first 50 torrents diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index 31df3ff8374..321265c2f79 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -34,11 +34,13 @@ def __init__(self): self.editchannel_request_mgr = None self.model = None self.controller = None + self.channel_dirty = False def initialize_edit_channel_page(self): self.window().create_channel_intro_button.clicked.connect(self.on_create_channel_intro_button_clicked) self.window().create_channel_form.hide() + self.update_channel_commit_views() self.window().edit_channel_stacked_widget.setCurrentIndex(1) self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_OVERVIEW) @@ -62,9 +64,11 @@ def initialize_edit_channel_page(self): self.controller = MyTorrentsTableViewController(self.model, self.window().edit_channel_torrents_container, self.window().edit_channel_torrents_num_items_label, self.window().edit_channel_torrents_filter) - self.window().edit_channel_torrents_container.details_tab_widget.hide() - self.window().dirty_channel_status_bar.hide() - self.window().edit_channel_commit_button.setEnabled(False) + self.window().edit_channel_torrents_container.details_container.hide() + + def update_channel_commit_views(self): + self.window().dirty_channel_status_bar.setHidden(not self.channel_dirty) + self.window().edit_channel_commit_button.setEnabled(self.channel_dirty) def load_my_channel_overview(self): if not self.channel_overview: @@ -82,9 +86,8 @@ def initialize_with_channel_overview(self, overview): return self.channel_overview = overview["mychannel"] - if self.channel_overview['dirty']: - self.window().dirty_channel_status_bar.show() - self.window().edit_channel_commit_button.setEnabled(True) + self.channel_dirty = self.channel_overview['dirty'] + self.update_channel_commit_views() self.window().export_channel_button.setHidden(False) self.window().edit_channel_name_label.setText("My channel") @@ -98,7 +101,6 @@ def initialize_with_channel_overview(self, overview): self.window().edit_channel_stacked_widget.setCurrentIndex(1) - # Initiate the right model self.model.channel_pk = self.channel_overview["public_key"] def on_create_channel_button_pressed(self): @@ -110,7 +112,7 @@ def on_create_channel_button_pressed(self): self.window().create_channel_button.setEnabled(False) self.editchannel_request_mgr = TriblerRequestManager() - self.editchannel_request_mgr.perform_request("channels/discovered", self.on_channel_created, + self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_created, data=(u'name=%s&description=%s' % (channel_name, channel_description)).encode('utf-8'), method='PUT') @@ -282,7 +284,7 @@ def on_add_torrents_browse_dir(self): def on_confirm_add_directory_dialog(self, action): if action == 0: - self.model.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) + self.add_dir_to_channel(self.chosen_dir, recursive=self.dialog.checkbox.isChecked()) if self.dialog: self.dialog.close_dialog() @@ -317,7 +319,7 @@ def on_add_torrent_browse_file(self): filename = QFileDialog.getOpenFileName(self, "Please select the .torrent file", "", "Torrent files (*.torrent)") if not filename[0]: return - self.model.add_torrent_to_channel(filename[0]) + self.add_torrent_to_channel(filename[0]) def on_add_torrent_from_url(self): self.dialog = ConfirmationDialog(self, "Add torrent from URL/magnet link", @@ -331,7 +333,7 @@ def on_add_torrent_from_url(self): def on_torrent_from_url_dialog_done(self, action): if action == 0: url = urllib.quote_plus(self.dialog.dialog_widget.dialog_input.text()) - self.model.add_torrent_url_to_channel(url) + self.add_torrent_url_to_channel(url) self.dialog.close_dialog() self.dialog = None @@ -345,8 +347,8 @@ def on_channel_committed(self, result): if not result: return if 'success' in result and result['success']: - self.window().dirty_channel_status_bar.hide() - self.window().edit_channel_commit_button.setEnabled(False) + self.channel_dirty = False + self.update_channel_commit_views() self.on_commit.emit() self.load_my_torrents() @@ -360,8 +362,7 @@ def add_torrent_to_channel(self, filename): def add_dir_to_channel(self, dirname, recursive=False): request_mgr = TriblerRequestManager() - request_mgr.perform_request("mychannel/torrents" % - self.channel_id, + request_mgr.perform_request("mychannel/torrents", self.on_torrent_to_channel_added, method='PUT', data=((u'torrents_dir=%s' % dirname) + (u'&recursive=1' if recursive else u'')).encode('utf-8')) diff --git a/TriblerGUI/widgets/homepage.py b/TriblerGUI/widgets/homepage.py index 312dc732135..75dd55bd6a3 100644 --- a/TriblerGUI/widgets/homepage.py +++ b/TriblerGUI/widgets/homepage.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from PyQt5.QtCore import QTimer from PyQt5.QtWidgets import QWidget from six.moves import xrange @@ -7,7 +8,6 @@ from TriblerGUI.defs import PAGE_CHANNEL_DETAILS from TriblerGUI.tribler_request_manager import TriblerRequestManager from TriblerGUI.widgets.home_recommended_item import HomeRecommendedItem -from TriblerGUI.widgets.loading_list_item import LoadingListItem class HomePage(QWidget): @@ -18,9 +18,9 @@ class HomePage(QWidget): def __init__(self): QWidget.__init__(self) - self.has_loaded_cells = False self.recommended_request_mgr = None self.show_channels = False + self.resize_event_timer = None def initialize_home_page(self): self.window().home_page_table_view.cellClicked.connect(self.on_home_page_item_clicked) @@ -28,13 +28,14 @@ def initialize_home_page(self): self.window().home_tab.initialize() self.window().home_tab.clicked_tab_button.connect(self.clicked_tab_button) - def load_cells(self): + def load_cells(self, num_items): self.window().home_page_table_view.clear() - for x in xrange(0, 3): - for y in xrange(0, 3): + for y in xrange(0, 3): + for x in xrange(0, 3): widget_item = HomeRecommendedItem(self) - self.window().home_page_table_view.setCellWidget(x, y, widget_item) - self.has_loaded_cells = True + self.window().home_page_table_view.setCellWidget(y, x, widget_item) + if y * 3 + x >= num_items - 1: + return def load_popular_torrents(self): self.recommended_request_mgr = TriblerRequestManager() @@ -49,53 +50,56 @@ def clicked_tab_button(self, tab_button_name): elif tab_button_name == "home_tab_torrents_button": self.load_popular_torrents() - def set_no_results_table(self, label_text): - self.has_loaded_cells = False - self.window().home_page_table_view.clear() - for x in xrange(0, 3): - for y in xrange(0, 3): - widget_item = LoadingListItem(self, label_text="") - self.window().home_page_table_view.setCellWidget(x, y, widget_item) - - self.window().home_page_table_view.setCellWidget( - 0, 1, LoadingListItem(self, label_text=label_text)) - self.window().resizeEvent(None) - def received_popular_channels(self, result): if not result: return self.show_channels = True - if not self.has_loaded_cells: - self.load_cells() if len(result["channels"]) == 0: - self.set_no_results_table(label_text="No recommended channels") + self.update_home_page_views(False) + self.window().home_page_no_items_label.setText("No recommended channels found.") return cur_ind = 0 + self.update_home_page_views(True) + self.load_cells(len(result["channels"][:9])) for channel in result["channels"][:9]: - self.window().home_page_table_view.cellWidget(cur_ind % 3, cur_ind / 3).update_with_channel(channel) + self.window().home_page_table_view.cellWidget(cur_ind / 3, cur_ind % 3).update_with_channel(channel) cur_ind += 1 - self.window().resizeEvent(None) + self.start_resize_timer() + + def update_home_page_views(self, has_results): + self.window().home_page_table_view.setHidden(not has_results) + self.window().home_page_no_items_label.setHidden(has_results) def received_popular_torrents(self, result): if not result: return self.show_channels = False - if not self.has_loaded_cells: - self.load_cells() if len(result["torrents"]) == 0: - self.set_no_results_table(label_text="No recommended torrents") + self.update_home_page_views(False) + self.window().home_page_no_items_label.setText("No recommended torrents found.") return cur_ind = 0 + self.update_home_page_views(True) + self.load_cells(len(result["torrents"][:9])) for torrent in result["torrents"][:9]: - self.window().home_page_table_view.cellWidget(cur_ind % 3, cur_ind / 3).update_with_torrent(torrent) + self.window().home_page_table_view.cellWidget(cur_ind / 3, cur_ind % 3).update_with_torrent(torrent) cur_ind += 1 - self.window().resizeEvent(None) + self.start_resize_timer() + + def start_resize_timer(self): + """ + For some magic Qt reason, invoking the resizeEvent immediately after loading the cell widgets is not working + correctly. As a workaround, call the resizeEvent after a small period of time. + """ + self.resize_event_timer = QTimer() + self.resize_event_timer.timeout.connect(lambda: self.window().resizeEvent(None)) + self.resize_event_timer.start(100) def on_home_page_item_clicked(self, row, col): cell_widget = self.window().home_page_table_view.cellWidget(row, col) diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 3be00e0aa97..32a803e9194 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -42,7 +42,7 @@ class SearchResultsTableView(TriblerContentTableView): """ This table displays search results, which can be both torrents and channels. """ - on_torrent_clicked = pyqtSignal(dict) + on_torrent_clicked = pyqtSignal(QModelIndex, dict) on_channel_clicked = pyqtSignal(dict) def __init__(self, parent=None): @@ -63,7 +63,7 @@ def on_table_item_clicked(self, item): self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) self.on_channel_clicked.emit(content_info) else: - self.on_torrent_clicked.emit(content_info) + self.on_torrent_clicked.emit(item, content_info) def resizeEvent(self, _): self.setColumnWidth(0, 100) @@ -76,7 +76,7 @@ class TorrentsTableView(TriblerContentTableView): """ This table displays various torrents. """ - on_torrent_clicked = pyqtSignal(dict) + on_torrent_clicked = pyqtSignal(QModelIndex, dict) def __init__(self, parent=None): TriblerContentTableView.__init__(self, parent) @@ -100,7 +100,7 @@ def on_table_item_clicked(self, item): return torrent_info = self.model().data_items[item.row()] - self.on_torrent_clicked.emit(torrent_info) + self.on_torrent_clicked.emit(item, torrent_info) def on_play_button_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] @@ -142,6 +142,9 @@ def on_torrent_status_updated(self, json_result, index): if 'success' in json_result and json_result['success']: index.model().data_items[index.row()][u'status'] = json_result['new_status'] + self.window().edit_channel_page.channel_dirty = json_result['dirty'] + self.window().edit_channel_page.update_channel_commit_views() + def resizeEvent(self, _): if isinstance(self.model(), MyTorrentsContentModel): self.setColumnWidth(0, 100) diff --git a/TriblerGUI/widgets/searchresultspage.py b/TriblerGUI/widgets/searchresultspage.py index ed6d3ca0c4e..6eac95819d5 100644 --- a/TriblerGUI/widgets/searchresultspage.py +++ b/TriblerGUI/widgets/searchresultspage.py @@ -23,15 +23,10 @@ def initialize_search_results_page(self): self.window().search_results_tab.clicked_tab_button.connect(self.clicked_tab_button) self.model = SearchResultsContentModel() self.controller = SearchResultsTableViewController(self.model, self.window().search_results_list, + self.window().search_details_container, self.window().num_search_results_label) self.window().search_details_container.details_tab_widget.initialize_details_widget() - self.window().search_results_list.on_torrent_clicked.connect(self.on_torrent_clicked) - - def on_torrent_clicked(self, torrent_info): - self.window().search_details_container.show() - self.window().search_details_container.details_tab_widget.update_with_torrent(torrent_info) - def perform_search(self, query): self.query = query self.model.reset() diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py index 0136c6bd8dc..74857133e91 100644 --- a/TriblerGUI/widgets/tablecontentdelegate.py +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -8,7 +8,7 @@ from TriblerGUI.defs import ACTION_BUTTONS, COMMIT_STATUS_COMMITTED, COMMIT_STATUS_NEW, COMMIT_STATUS_TODELETE, \ HEALTH_CHECKING, HEALTH_DEAD, HEALTH_ERROR, HEALTH_GOOD, HEALTH_MOOT, HEALTH_UNCHECKED -from TriblerGUI.utilities import get_image_path +from TriblerGUI.utilities import get_image_path, get_health from TriblerGUI.widgets.tableiconbuttons import DownloadIconButton, PlayIconButton @@ -443,20 +443,20 @@ def on_mouse_moved(self, pos, index): class HealthStatusDisplay(QObject): indicator_side = 10 - indicator_border = 2 + indicator_border = 6 health_colors = { HEALTH_GOOD: QColor(Qt.green), HEALTH_DEAD: QColor(Qt.red), HEALTH_MOOT: QColor(Qt.yellow), HEALTH_UNCHECKED: QColor("#B5B5B5"), - HEALTH_CHECKING: QColor(Qt.blue), - HEALTH_ERROR: QColor(Qt.cyan) + HEALTH_CHECKING: QColor(Qt.yellow), + HEALTH_ERROR: QColor(Qt.red) } def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, alignment=Qt.AlignVCenter): painter.save() - text_flags = Qt.AlignHCenter | alignment | Qt.TextSingleLine + text_flags = Qt.AlignLeft | alignment | Qt.TextSingleLine text_box = painter.boundingRect(rect, text_flags, text) painter.setPen(QPen(color, 1, Qt.SolidLine, Qt.RoundCap)) if font: @@ -468,10 +468,10 @@ def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, ali def paint(self, painter, rect, index): data_item = index.model().data_items[index.row()] - if u'health' in data_item: - health = data_item[u'health'] - else: - health = HEALTH_UNCHECKED + if u'health' not in data_item: + data_item[u'health'] = get_health(data_item['num_seeders'], data_item['num_leechers'], + data_item['last_tracker_check']) + health = data_item[u'health'] # ---------------- # |b---b| | @@ -491,7 +491,7 @@ def paint(self, painter, rect, index): # Paint indicator painter.save() painter.setBrush(QBrush(self.health_colors[health])) - painter.setPen(QPen(QColor(Qt.darkGray), 0, Qt.SolidLine, Qt.RoundCap)) + painter.setPen(QPen(self.health_colors[health], 0, Qt.SolidLine, Qt.RoundCap)) painter.drawEllipse(indicator_rect) painter.restore() diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index aeb4c33a9e5..b3733e7a463 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -98,10 +98,10 @@ class SearchResultsContentModel(TriblerContentModel): columns = [u'category', u'name', u'health', ACTION_BUTTONS] column_headers = [u'Category', u'Name', u'health', u''] column_flags = { - u'category': Qt.ItemIsEnabled, - u'name': Qt.ItemIsEnabled, - u'health': Qt.ItemIsEnabled, - ACTION_BUTTONS: Qt.ItemIsEnabled + u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + u'health': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable } def __init__(self): @@ -144,75 +144,8 @@ class TorrentsContentModel(TriblerContentModel): def __init__(self, channel_pk=''): TriblerContentModel.__init__(self) - self.channel_pk = channel_pk - # This dict keeps the mapping of infohashes in data_items to indexes - # It is used by Health Checker to track the health status updates across model refreshes - self.infohashes = {} - self.last_health_check_ts = {} - - def reset(self): - # Health Checker related - # Infohash to data_items mapping should be cleaned each time we refresh the model - self.infohashes.clear() - super(TorrentsContentModel, self).reset() - - def update_torrent_health(self, infohash, seeders, leechers, health): - if infohash in self.infohashes: - row = self.infohashes[infohash] - self.data_items[row][u'num_seeders'] = seeders - self.data_items[row][u'num_leechers'] = leechers - self.data_items[row][u'health'] = health - index = self.index(row, self.column_position[u'health']) - self.dataChanged.emit(index, index, []) - - def check_torrent_health(self, index): - timeout = 15 - infohash = self.data_items[index.row()][u'infohash'] - - # TODO: move timeout check to the endpoint - if infohash in self.last_health_check_ts and \ - (time.time() - self.last_health_check_ts[infohash] < timeout): - return - self.last_health_check_ts[infohash] = time.time() - - def on_cancel_health_check(): - pass - - def on_health_response(response): - self.last_health_check_ts[infohash] = time.time() - total_seeders = 0 - total_leechers = 0 - - if not response or 'error' in response: - self.update_torrent_health(infohash, 0, 0, HEALTH_ERROR) # Just set the health to 0 seeders, 0 leechers - return - - for _, status in response['health'].iteritems(): - if 'error' in status: - continue # Timeout or invalid status - total_seeders += int(status['seeders']) - total_leechers += int(status['leechers']) - - if total_seeders > 0: - health = HEALTH_GOOD - elif total_leechers > 0: - health = HEALTH_MOOT - else: - health = HEALTH_DEAD - - self.update_torrent_health(infohash, total_seeders, total_leechers, health) - - self.data_items[index.row()][u'health'] = HEALTH_CHECKING - index_upd = self.index(index.row(), self.column_position[u'health']) - self.dataChanged.emit(index_upd, index_upd, []) - health_request_mgr = TriblerRequestManager() - health_request_mgr.perform_request("torrents/%s/health?timeout=%s&refresh=%d" % - (infohash, timeout, 1), - on_health_response, capture_errors=False, priority="LOW", - on_cancel=on_cancel_health_check) - class MyTorrentsContentModel(TorrentsContentModel): columns = [u'category', u'name', u'size', u'status'] diff --git a/TriblerGUI/widgets/torrentdetailstabwidget.py b/TriblerGUI/widgets/torrentdetailstabwidget.py index 3cd3799731d..d190d7673fb 100644 --- a/TriblerGUI/widgets/torrentdetailstabwidget.py +++ b/TriblerGUI/widgets/torrentdetailstabwidget.py @@ -1,21 +1,21 @@ from __future__ import absolute_import import logging +import time -from PyQt5.QtCore import pyqtSignal +from PyQt5.QtCore import pyqtSignal, QModelIndex from PyQt5.QtWidgets import QLabel, QTabWidget, QTreeWidget, QTreeWidgetItem -from TriblerGUI.defs import HEALTH_CHECKING +from TriblerGUI.defs import HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_GOOD, HEALTH_MOOT from TriblerGUI.tribler_request_manager import TriblerRequestManager -from TriblerGUI.utilities import format_size +from TriblerGUI.utilities import format_size, get_health from TriblerGUI.widgets.ellipsebutton import EllipseButton class TorrentDetailsTabWidget(QTabWidget): - health_check_clicked = pyqtSignal(dict) """ The TorrentDetailsTabWidget is the tab that provides details about a specific selected torrent. This information - includes the generic info about the torrent, files and trackers. + includes the generic info about the torrent and trackers. """ def __init__(self, parent): @@ -27,13 +27,12 @@ def __init__(self, parent): self.torrent_detail_category_label = None self.torrent_detail_size_label = None self.torrent_detail_health_label = None - self.torrent_detail_files_list = None self.torrent_detail_trackers_list = None self.check_health_button = None self.request_mgr = None self.health_request_mgr = None self.is_health_checking = False - self.last_health_check_ts = -1 + self.index = QModelIndex() def initialize_details_widget(self): """ @@ -44,45 +43,37 @@ def initialize_details_widget(self): self.torrent_detail_category_label = self.findChild(QLabel, "torrent_detail_category_label") self.torrent_detail_size_label = self.findChild(QLabel, "torrent_detail_size_label") self.torrent_detail_health_label = self.findChild(QLabel, "torrent_detail_health_label") - self.torrent_detail_files_list = self.findChild(QTreeWidget, "torrent_detail_files_list") self.torrent_detail_trackers_list = self.findChild(QTreeWidget, "torrent_detail_trackers_list") self.setCurrentIndex(0) self.check_health_button = self.findChild(EllipseButton, "check_health_button") - self.check_health_button.clicked.connect(lambda: self.on_check_health_clicked(timeout=15)) + self.check_health_button.clicked.connect(self.on_check_health_clicked) def on_torrent_info(self, torrent_info): if not torrent_info: return self.setTabEnabled(1, True) - self.setTabEnabled(2, True) - self.torrent_detail_files_list.clear() self.torrent_detail_trackers_list.clear() - for file_info in torrent_info["files"]: - item = QTreeWidgetItem(self.torrent_detail_files_list) - item.setText(0, file_info["path"]) - item.setText(1, format_size(float(file_info["size"]))) - - for tracker in torrent_info["trackers"]: - if tracker == 'DHT': - continue + for tracker in torrent_info["torrent"]["trackers"]: item = QTreeWidgetItem(self.torrent_detail_trackers_list) item.setText(0, tracker) - if torrent_info["num_seeders"] > 0: - self.torrent_detail_health_label.setText("good health (S%d L%d)" % (torrent_info["num_seeders"], - torrent_info["num_leechers"])) - elif torrent_info["num_leechers"] > 0: - self.torrent_detail_health_label.setText("unknown health (found peers)") - elif self.is_health_checking or (u'health' in torrent_info and torrent_info[u'health'] == HEALTH_CHECKING): - self.torrent_detail_health_label.setText("Checking...") - else: - self.torrent_detail_health_label.setText("no peers found") + if self.is_health_checking: + self.health_request_mgr.cancel_request() + self.is_health_checking = False + + self.update_health_label(torrent_info["torrent"]['num_seeders'], torrent_info["torrent"]['num_leechers'], + torrent_info["torrent"]['last_tracker_check']) - def update_with_torrent(self, torrent_info): + # If we do not have the health of this torrent, query it + if torrent_info['torrent']['last_tracker_check'] == 0: + self.check_torrent_health() + + def update_with_torrent(self, index, torrent_info): self.torrent_info = torrent_info + self.index = index self.torrent_detail_name_label.setText(self.torrent_info["name"]) if self.torrent_info["category"]: self.torrent_detail_category_label.setText(self.torrent_info["category"].lower()) @@ -94,50 +85,81 @@ def update_with_torrent(self, torrent_info): else: self.torrent_detail_size_label.setText("%s" % format_size(float(self.torrent_info["size"]))) - if self.torrent_info["num_seeders"] > 0: - self.torrent_detail_health_label.setText("good health (S%d L%d)" % (self.torrent_info["num_seeders"], - self.torrent_info["num_leechers"])) - elif self.torrent_info["num_leechers"] > 0: - self.torrent_detail_health_label.setText("unknown health (found peers)") - elif self.is_health_checking or (u'health' in torrent_info and torrent_info[u'health'] == HEALTH_CHECKING): - self.torrent_detail_health_label.setText("Checking...") - else: - self.torrent_detail_health_label.setText("no peers found") + self.update_health_label(torrent_info['num_seeders'], torrent_info['num_leechers'], + torrent_info['last_tracker_check']) self.setCurrentIndex(0) self.setTabEnabled(1, False) - self.setTabEnabled(2, False) self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("torrents/%s" % self.torrent_info["infohash"], self.on_torrent_info) + self.request_mgr.perform_request("metadata/torrents/%s" % self.torrent_info["infohash"], self.on_torrent_info) - def on_check_health_clicked(self, timeout=15): - self.health_check_clicked.emit(self.torrent_info) + def on_check_health_clicked(self): + if not self.is_health_checking: + self.check_torrent_health() - def update_health(self, seeders, leechers, health=None): + def update_health_label(self, seeders, leechers, last_tracker_check): try: - if seeders > 0: - self.torrent_detail_health_label.setText("good health (S%d L%d)" % (seeders, leechers)) - elif leechers > 0: - self.torrent_detail_health_label.setText("unknown health (found peers)") - elif health == HEALTH_CHECKING: - self.torrent_detail_health_label.setText("Checking...") + health = get_health(seeders, leechers, last_tracker_check) + + if health == HEALTH_UNCHECKED: + self.torrent_detail_health_label.setText("Unknown health") + elif health == HEALTH_GOOD: + self.torrent_detail_health_label.setText("Good health (S%d L%d)" % (seeders, leechers)) + elif health == HEALTH_MOOT: + self.torrent_detail_health_label.setText("Unknown health (found peers)") else: - self.torrent_detail_health_label.setText("no peers found") + self.torrent_detail_health_label.setText("No peers found") except RuntimeError: self._logger.error("The underlying GUI widget has already been removed.") - def on_cancel_health_check(self): - self.is_health_checking = False + def check_torrent_health(self): + timeout = 15 + infohash = self.torrent_info[u'infohash'] + + def on_cancel_health_check(): + self.is_health_checking = False + + if u'health' in self.index.model().column_position: + self.index.model().data_items[self.index.row()][u'health'] = HEALTH_CHECKING + index = self.index.model().index(self.index.row(), self.index.model().column_position[u'health']) + self.index.model().dataChanged.emit(index, index, []) + + self.torrent_detail_health_label.setText("Checking...") + self.health_request_mgr = TriblerRequestManager() + self.health_request_mgr.perform_request("metadata/torrents/%s/health?timeout=%s&refresh=%d" % + (infohash, timeout, 1), + self.on_health_response, capture_errors=False, priority="LOW", + on_cancel=on_cancel_health_check) + + def on_health_response(self, response): + total_seeders = 0 + total_leechers = 0 - def update_from_model(self, i1, i2, role): - if not self.torrent_info: + if not response or 'error' in response: + self.update_torrent_health(0, 0) # Just set the health to 0 seeders, 0 leechers return - # We only react to very specific update type that was generated by our actions - if i1.row() == i2.row(): - torrent_info = i1.model().data_items[i1.row()] - if self.torrent_info[u'infohash'] == torrent_info[u'infohash']: - self.is_health_checking = torrent_info[u'health'] == HEALTH_CHECKING - self.update_health(torrent_info[u'num_seeders'], torrent_info[u'num_leechers'], - health=torrent_info[u'health']) + for _, status in response['health'].items(): + if 'error' in status: + continue # Timeout or invalid status + total_seeders += int(status['seeders']) + total_leechers += int(status['leechers']) + + self.update_torrent_health(total_seeders, total_leechers) + + def update_torrent_health(self, seeders, leechers): + data_item = self.index.model().data_items[self.index.row()] + data_item[u'num_seeders'] = seeders + data_item[u'num_leechers'] = leechers + data_item[u'last_tracker_check'] = time.time() + data_item[u'health'] = get_health(data_item[u'num_seeders'], data_item[u'num_leechers'], + data_item[u'last_tracker_check']) + + if u'health' in self.index.model().column_position: + index = self.index.model().index(self.index.row(), self.index.model().column_position[u'health']) + self.index.model().dataChanged.emit(index, index, []) + + # Update the health label of the detail widget + self.update_health_label(data_item[u'num_seeders'], data_item[u'num_leechers'], + data_item[u'last_tracker_check']) diff --git a/TriblerGUI/widgets/torrentslistwidget.py b/TriblerGUI/widgets/torrentslistwidget.py index d55e4184e12..ae466d80d5b 100644 --- a/TriblerGUI/widgets/torrentslistwidget.py +++ b/TriblerGUI/widgets/torrentslistwidget.py @@ -20,19 +20,3 @@ def __init__(self, parent=None): self.details_tab_widget = self.findChild(TorrentDetailsTabWidget, "details_tab_widget") self.details_tab_widget.initialize_details_widget() - self.details_tab_widget.health_check_clicked.connect(self.on_details_tab_widget_health_check_clicked) - - def on_details_tab_widget_health_check_clicked(self, torrent_info): - infohash = torrent_info[u'infohash'] - if infohash in self.model.infohashes: - self.model.check_torrent_health(self.model.index(self.model.infohashes[infohash], 0)) - - # def on_table_item_clicked(self, item): - # if item.column() == self.content_table.model().column_position[ACTION_BUTTONS] - # return - # table_entry = self.content_table.model().data_items[item.row()] - # if table_entry['type'] == u'torrent': - # self.details_tab_widget.update_with_torrent(table_entry) - # self.model.check_torrent_health(item) - # elif table_entry['type'] == u'channel': - # self.on_torrent_clicked.emit(table_entry) diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index 4adb780b006..8ac0e33e10e 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -40,10 +40,26 @@ class SearchResultsTableViewController(TriblerTableViewController): Controller for the table view that handles search results. """ - def __init__(self, model, table_view, num_search_results_label=None): + def __init__(self, model, table_view, details_container, num_search_results_label=None): TriblerTableViewController.__init__(self, model, table_view) self.num_search_results_label = num_search_results_label + self.details_container = details_container self.query = None + table_view.selectionModel().selectionChanged.connect(self._on_selection_changed) + + def _on_selection_changed(self, _): + selected_indices = self.table_view.selectedIndexes() + if not selected_indices: + return + + torrent_info = selected_indices[0].model().data_items[selected_indices[0].row()] + if torrent_info['type'] == 'channel': + self.details_container.hide() + self.table_view.clearSelection() + return + + self.details_container.show() + self.details_container.details_tab_widget.update_with_torrent(selected_indices[0], torrent_info) def _on_view_sort(self, column, ascending): self.model.reset() @@ -155,13 +171,24 @@ class TorrentsTableViewController(TriblerTableViewController): def __init__(self, model, torrents_container, num_torrents_label=None, filter_input=None): TriblerTableViewController.__init__(self, model, torrents_container.content_table) + self.torrents_container = torrents_container self.num_torrents_label = num_torrents_label self.filter_input = filter_input + torrents_container.content_table.selectionModel().selectionChanged.connect(self._on_selection_changed) if self.filter_input: self.filter_input.textChanged.connect(self._on_filter_input_change) - def _on_filter_input_change(self, text): + def _on_selection_changed(self, _): + selected_indices = self.table_view.selectedIndexes() + if not selected_indices: + return + + self.torrents_container.details_container.show() + torrent_info = selected_indices[0].model().data_items[selected_indices[0].row()] + self.torrents_container.details_tab_widget.update_with_torrent(selected_indices[0], torrent_info) + + def _on_filter_input_change(self, _): self.model.reset() self.load_torrents(1, 50) @@ -248,5 +275,5 @@ def on_torrents(self, response): if response['first'] >= self.model.rowCount(): self.model.add_items(response['torrents']) - self.table_view.window().dirty_channel_status_bar.setHidden(not response['dirty']) - self.table_view.window().edit_channel_commit_button.setEnabled(response['dirty']) + self.table_view.window().edit_channel_page.channel_dirty = response['dirty'] + self.table_view.window().edit_channel_page.update_channel_commit_views() diff --git a/logger.conf b/logger.conf index 04cc156be80..5005203fa26 100644 --- a/logger.conf +++ b/logger.conf @@ -1,5 +1,5 @@ [loggers] -keys=root,candidates,twisted, +keys=root,twisted, TriblerGUI, RequestCache, diff --git a/run_tribler.py b/run_tribler.py index 2c0f336b926..0639e23c769 100644 --- a/run_tribler.py +++ b/run_tribler.py @@ -115,7 +115,15 @@ def start_tribler(): app = TriblerApplication("triblerapp", sys.argv) - window = TriblerWindow(api_port=random.randint(10000,20000)) + if app.is_running(): + for arg in sys.argv[1:]: + if os.path.exists(arg) and arg.endswith(".torrent"): + app.send_message("file:%s" % arg) + elif arg.startswith('magnet'): + app.send_message(arg) + sys.exit(1) + + window = TriblerWindow() window.setWindowTitle("Tribler") app.set_activation_window(window) app.parse_sys_args(sys.argv) From 177460057f6b6587a401cae9a8fad4a69055e3f9 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Tue, 15 Jan 2019 11:02:50 +0100 Subject: [PATCH 11/38] Removed chant checkbox from settings --- TriblerGUI/qt_resources/mainwindow.ui | 14 ++------------ TriblerGUI/widgets/settingspage.py | 4 ---- 2 files changed, 2 insertions(+), 16 deletions(-) diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 8906a8cc7f3..42be3f9a3d9 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -1090,7 +1090,7 @@ background-color: #e67300; - 1 + 4 @@ -4387,7 +4387,7 @@ border-top: 1px solid #555; 0 0 - 300 + 661 616
@@ -4808,16 +4808,6 @@ color: white; - - - - margin-top: 2px; - - - - - - diff --git a/TriblerGUI/widgets/settingspage.py b/TriblerGUI/widgets/settingspage.py index cc25c166520..d6c6bb70084 100644 --- a/TriblerGUI/widgets/settingspage.py +++ b/TriblerGUI/widgets/settingspage.py @@ -267,9 +267,6 @@ def initialize_with_settings(self, settings): self.window().credit_mining_enabled_checkbox.setChecked(settings['credit_mining']['enabled']) self.window().max_disk_space_input.setText(str(settings['credit_mining']['max_disk_space'])) - # chant settings - self.window().chant_channel_edit.setChecked(settings['chant']['channel_edit']) - # Debug self.window().developer_mode_enabled_checkbox.setChecked(get_gui_setting(gui_settings, "debug", False, is_bool=True)) @@ -333,7 +330,6 @@ def save_settings(self): settings_data['general']['family_filter'] = self.window().family_filter_checkbox.isChecked() settings_data['download_defaults']['saveas'] = self.window().download_location_input.text().encode('utf-8') settings_data['general']['log_dir'] = self.window().log_location_input.text() - settings_data['chant']['channel_edit'] = self.window().chant_channel_edit.isChecked() settings_data['watch_folder']['enabled'] = self.window().watchfolder_enabled_checkbox.isChecked() if settings_data['watch_folder']['enabled']: From eb64e33cd8e071e788cf5ff7ee3a0b87cdf2102c Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Tue, 15 Jan 2019 11:05:08 +0100 Subject: [PATCH 12/38] Changed community identifiers --- Tribler/Core/Libtorrent/LibtorrentMgr.py | 2 +- .../MetadataStore/OrmBindings/author.py | 2 +- .../OrmBindings/channel_metadata.py | 35 +-- .../MetadataStore/OrmBindings/channel_node.py | 202 ++++++++++++++++- .../MetadataStore/OrmBindings/metadata.py | 183 ---------------- .../OrmBindings/torrent_metadata.py | 23 +- .../OrmBindings/torrent_state.py | 2 +- .../Modules/MetadataStore/serialization.py | 112 +++++----- Tribler/Core/Modules/MetadataStore/store.py | 35 ++- Tribler/Core/Modules/gigachannel_manager.py | 27 ++- .../Core/Modules/restapi/metadata_endpoint.py | 4 +- .../Modules/restapi/mychannel_endpoint.py | 13 +- Tribler/Core/TorrentChecker/session.py | 22 +- .../Core/TorrentChecker/torrent_checker.py | 6 +- Tribler/Core/Upgrade/db71_to_pony.py | 171 ++++++++++----- Tribler/Core/Utilities/tracker_utils.py | 5 +- .../Community/gigachannel/test_community.py | 2 +- .../Modules/MetadataStore/gen_test_data.py | 4 +- .../MetadataStore/test_channel_download.py | 3 +- .../MetadataStore/test_channel_metadata.py | 17 +- .../Modules/MetadataStore/test_metadata.py | 39 ++-- .../Core/Modules/MetadataStore/test_store.py | 17 +- .../MetadataStore/test_torrent_metadata.py | 6 +- .../RestApi/test_downloads_endpoint.py | 2 +- .../Modules/RestApi/test_metadata_endpoint.py | 8 +- .../RestApi/test_mychannel_endpoint.py | 2 +- .../Test/Core/Utilities/test_tracker_utils.py | 11 +- .../000000000002.mdblob.lz4 | Bin 0 -> 283 bytes .../000000000006.mdblob.lz4 | Bin 0 -> 539 bytes .../000000000007.mdblob.lz4 | Bin 0 -> 211 bytes .../000000000009.mdblob.lz4 | Bin 0 -> 223 bytes .../Core/data/sample_channel/channel.mdblob | Bin 236 -> 236 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 236 -> 236 bytes .../data/sample_channel/channel_upd.torrent | 2 +- .../000000000002.mdblob.lz4 | Bin 283 -> 0 bytes .../000000000007.mdblob.lz4 | Bin 541 -> 0 bytes .../000000000009.mdblob.lz4 | Bin 211 -> 0 bytes .../000000000012.mdblob.lz4 | Bin 222 -> 0 bytes Tribler/community/gigachannel/community.py | 25 ++- TriblerGUI/tribler_app.py | 5 +- TriblerGUI/widgets/editchannelpage.py | 10 +- TriblerGUI/widgets/lazytableview.py | 205 +++++++++--------- TriblerGUI/widgets/tablecontentdelegate.py | 7 +- TriblerGUI/widgets/tablecontentmodel.py | 4 +- TriblerGUI/widgets/torrentdetailstabwidget.py | 21 +- TriblerGUI/widgets/triblertablecontrollers.py | 34 ++- run_tribler.py | 13 +- 48 files changed, 708 insertions(+), 575 deletions(-) delete mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py create mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000002.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000002.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000012.mdblob.lz4 diff --git a/Tribler/Core/Libtorrent/LibtorrentMgr.py b/Tribler/Core/Libtorrent/LibtorrentMgr.py index 459cdd81701..f126278104a 100644 --- a/Tribler/Core/Libtorrent/LibtorrentMgr.py +++ b/Tribler/Core/Libtorrent/LibtorrentMgr.py @@ -151,7 +151,7 @@ def create_session(self, hops=0, store_listen_port=True): # the settings dictionary settings['outgoing_port'] = 0 settings['num_outgoing_ports'] = 1 - settings['allow_multiple_connections_per_ip'] = 1 + settings['allow_multiple_connections_per_ip'] = 0 # Copy construct so we don't modify the default list extensions = list(DEFAULT_LT_EXTENSIONS) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py index e10393905ea..b2fa51a4657 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py @@ -6,6 +6,6 @@ def define_binding(db): class Author(db.Entity): public_key = orm.PrimaryKey(database_blob) - authored = orm.Set('Metadata') + authored = orm.Set('ChannelNode') return Author diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 2998f6e2b96..cdcea3842cc 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -3,16 +3,16 @@ import os from binascii import hexlify from datetime import datetime - from libtorrent import add_files, bencode, create_torrent, file_storage, set_piece_hashes, torrent_info import lz4.frame - from pony import orm from pony.orm import db_session, raw_sql, select -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import COMMITTED, NEW, PUBLIC_KEY_LEN, TODELETE +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import COMMITTED, NEW, PUBLIC_KEY_LEN, TODELETE, \ + LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, ChannelMetadataPayload +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url from Tribler.Core.exceptions import DuplicateChannelNameError, DuplicateTorrentFileError from Tribler.pyipv8.ipv8.database import database_blob @@ -94,9 +94,6 @@ class ChannelMetadata(db.TorrentMetadata): def update_metadata(self, update_dict=None): channel_dict = self.to_dict() channel_dict.update(update_dict or {}) - channel_dict.update({ - "size": self.contents_len, - }) self.set(**channel_dict) self.sign() @@ -170,20 +167,24 @@ def update_channel_torrent(self, metadata_list): os.makedirs(channel_dir) index = 0 + new_timestamp = self.timestamp while index < len(metadata_list): # Squash several serialized and signed metadata entries into a single file data, index = entries_to_chunk(metadata_list, self._CHUNK_SIZE_LIMIT, start_index=index) - blob_filename = str(self._clock.tick()).zfill(12) + BLOB_EXTENSION + '.lz4' + new_timestamp = self._clock.tick() + blob_filename = str(new_timestamp).zfill(12) + BLOB_EXTENSION + '.lz4' with open(os.path.join(channel_dir, blob_filename), 'wb') as f: f.write(data) + # TODO: add error-handling routines to make sure the timestamp is not messed up in case of an error + # Make torrent out of dir with metadata files torrent, infohash = create_torrent_from_dir(channel_dir, os.path.join(self._channels_dir, self.dir_name + ".torrent")) torrent_date = datetime.utcfromtimestamp(torrent['creation date']) return {"infohash": infohash, "num_entries": self.contents_len, - "timestamp": self._clock.tick(), "torrent_date": torrent_date} + "timestamp": new_timestamp, "torrent_date": torrent_date} def commit_channel_torrent(self): """ @@ -248,7 +249,7 @@ def add_torrent_to_channel(self, tdef, extra_info): "tags": tags, "size": tdef.get_length(), "torrent_date": datetime.fromtimestamp(tdef.get_creation_date()), - "tracker_info": tdef.get_tracker() or '', + "tracker_info": get_uniformed_tracker_url(tdef.get_tracker()) or '', "status": NEW }) torrent_metadata.parents.add(self) @@ -379,9 +380,8 @@ def get_random_channels(cls, limit, subscribed=False): :return: the subset of random channels we are subscribed to :rtype: list """ - if subscribed: - return db.ChannelMetadata.select(lambda g: g.subscribed).random(limit) - return db.ChannelMetadata.select().random(limit) + return db.ChannelMetadata.select(lambda g: g.subscribed == subscribed and g.status != LEGACY_ENTRY).random( + limit) @db_session def get_random_torrents(self, limit): @@ -405,7 +405,8 @@ def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - pony_query = ChannelMetadata.get_entries_query(sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + pony_query = ChannelMetadata.get_entries_query(sort_by=sort_by, sort_asc=sort_asc, + query_filter=query_filter) # Filter subscribed/non-subscribed if subscribed: @@ -413,7 +414,7 @@ def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte total_results = pony_query.count() - return pony_query[first-1:last], total_results + return pony_query[first - 1:last], total_results @db_session def to_simple_dict(self): @@ -426,7 +427,11 @@ def to_simple_dict(self): "name": self.title, "torrents": self.contents_len, "subscribed": self.subscribed, - "votes": self.votes + "votes": self.votes, + "status": self.status, + + # TODO: optimize this? + "my_channel": database_blob(self._my_key.pub().key_to_bin()[10:]) == database_blob(self.public_key) } return ChannelMetadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index 0b365801eae..755040ff943 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -1,26 +1,222 @@ +from __future__ import absolute_import + +from binascii import hexlify +from datetime import datetime + from pony import orm +from pony.orm import db_session, select, desc +from pony.orm.core import DEFAULT + +from Tribler.Core.Modules.MetadataStore.serialization import DeletedMetadataPayload, DELETED, \ + ChannelNodePayload, CHANNEL_NODE +from Tribler.Core.exceptions import InvalidSignatureException +from Tribler.pyipv8.ipv8.database import database_blob +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + +# Metadata, torrents and channel statuses +NEW = 0 +TODELETE = 1 +COMMITTED = 2 +JUST_RECEIVED = 3 +UPDATE_AVAILABLE = 4 +PREVIEW_UPDATE_AVAILABLE = 5 +LEGACY_ENTRY = 6 + +PUBLIC_KEY_LEN = 64 -from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_NODE, ChannelNodePayload + +def generate_dict_from_pony_args(cls, skip_list=None, **kwargs): + """ + Note: this is a way to manually define Pony entity default attributes in case we really + have to generate the signature before creating the object + """ + d = {} + skip_list = skip_list or [] + for attr in cls._attrs_: + val = kwargs.get(attr.name, DEFAULT) + if attr.name in skip_list: + continue + d[attr.name] = attr.validate(val, entity=cls) + return d def define_binding(db): - class ChannelNode(db.Metadata): + class ChannelNode(db.Entity): _discriminator_ = CHANNEL_NODE + rowid = orm.PrimaryKey(int, auto=True) + # Serializable - id_ = orm.Optional(int, size=64, default=0) + metadata_type = orm.Discriminator(int) origin_id = orm.Optional(int, size=64, default=0) + public_key = orm.Required(database_blob) + id_ = orm.Required(int, size=64) + # orm.composite_key(public_key, id_) # Requires Pony 0.7.7+ with Python2 + orm.composite_index(public_key, id_) # Requires Pony 0.7.7+ with Python2 + + signature = orm.Required(database_blob) + # Local + addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) + status = orm.Optional(int, default=COMMITTED) + parents = orm.Set('ChannelNode', reverse='children') children = orm.Set('ChannelNode', reverse='parents') # Special properties _payload_class = ChannelNodePayload + _my_key = None + _logger = None + _clock = None def __init__(self, *args, **kwargs): + """ + Initialize a metadata object. + All this dance is required to ensure that the signature is there and it is correct. + """ + + # Process special keyworded arguments + # "sign_with" argument given, sign with it + private_key_override = None + if "sign_with" in kwargs: + kwargs["public_key"] = database_blob(kwargs["sign_with"].pub().key_to_bin()[10:]) + private_key_override = kwargs["sign_with"] + kwargs.pop("sign_with") + + # For putting legacy/test stuff in + skip_key_check = False + if "skip_key_check" in kwargs and kwargs["skip_key_check"]: + skip_key_check = True + kwargs.pop("skip_key_check") + if "id_" not in kwargs: kwargs["id_"] = self._clock.tick() + + if not private_key_override and not skip_key_check: + # No key/signature given, sign with our own key. + if ("signature" not in kwargs) and \ + (("public_key" not in kwargs) or ( + kwargs["public_key"] == database_blob(self._my_key.pub().key_to_bin()[10:]))): + private_key_override = self._my_key + + # Key/signature given, check them for correctness + elif ("public_key" in kwargs) and ("signature" in kwargs): + try: + self._payload_class(**kwargs) + except InvalidSignatureException: + raise InvalidSignatureException( + ("Attempted to create %s object with invalid signature/PK: " % str( + self.__class__.__name__)) + + (hexlify(kwargs["signature"]) if "signature" in kwargs else "empty signature ") + " / " + + (hexlify(kwargs["public_key"]) if "public_key" in kwargs else " empty PK")) + + if private_key_override: + # Get default values for Pony class attributes. We have to do it manually because we need + # to know the payload signature *before* creating the object. + kwargs = generate_dict_from_pony_args(self.__class__, skip_list=["signature", "public_key"], **kwargs) + payload = self._payload_class( + **dict(kwargs, + public_key=str(private_key_override.pub().key_to_bin()[10:]), + key=private_key_override, + metadata_type=self.metadata_type)) + kwargs["public_key"] = payload.public_key + kwargs["signature"] = payload.signature + super(ChannelNode, self).__init__(*args, **kwargs) + def _serialized(self, key=None): + """ + Serializes the object and returns the result with added signature (tuple output) + :param key: private key to sign object with + :return: (serialized_data, signature) tuple + """ + return self._payload_class(key=key, **self.to_dict())._serialized() + + def serialized(self, key=None): + """ + Serializes the object and returns the result with added signature (blob output) + :param key: private key to sign object with + :return: serialized_data+signature binary string + """ + return ''.join(self._serialized(key)) + + def _serialized_delete(self): + """ + Create a special command to delete this metadata and encode it for transfer (tuple output). + :return: (serialized_data, signature) tuple + """ + my_dict = ChannelNode.to_dict(self) + my_dict.update({"metadata_type": DELETED, + "delete_signature": self.signature}) + return DeletedMetadataPayload(key=self._my_key, **my_dict)._serialized() + + def serialized_delete(self): + """ + Create a special command to delete this metadata and encode it for transfer (blob output). + :return: serialized_data+signature binary string + """ + return ''.join(self._serialized_delete()) + + def to_file(self, filename, key=None): + with open(filename, 'wb') as output_file: + output_file.write(self.serialized(key)) + + def to_delete_file(self, filename): + with open(filename, 'wb') as output_file: + output_file.write(self.serialized_delete()) + + def sign(self, key=None): + if not key: + key = self._my_key + self.public_key = database_blob(key.pub().key_to_bin()[10:]) + _, self.signature = self._serialized(key) + + def has_valid_signature(self): + crypto = default_eccrypto + signature_correct = False + key_correct = crypto.is_valid_public_bin(b"LibNaCLPK:" + str(self.public_key)) + + if key_correct: + try: + self._payload_class(**self.to_dict()) + except InvalidSignatureException: + signature_correct = False + else: + signature_correct = True + + return key_correct and signature_correct + + @classmethod + def from_payload(cls, payload): + return cls(**payload.to_dict()) + + @classmethod + def from_dict(cls, dct): + return cls(**dct) + + @classmethod + @db_session + def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): + """ + Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based + on a keyword/whether you are subscribed to it. + :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates + the total number of results, regardless the passed first/last parameter. + """ + # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! + pony_query = select(g for g in cls) + + # Filter the results on a keyword or some keywords + if query_filter: + pony_query = cls.search_keyword(query_filter + "*", lim=1000) + + # Sort the query + if sort_by: + sort_expression = "g." + sort_by + sort_expression = sort_expression if sort_asc else desc(sort_expression) + pony_query = pony_query.sort_by(sort_expression) + + return pony_query + return ChannelNode diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py deleted file mode 100644 index c4c1cf7f274..00000000000 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/metadata.py +++ /dev/null @@ -1,183 +0,0 @@ -from __future__ import absolute_import - -from binascii import hexlify -from datetime import datetime - -from pony import orm -from pony.orm import db_session, select, desc - -from Tribler.Core.Modules.MetadataStore.serialization import MetadataPayload, DeletedMetadataPayload, TYPELESS, DELETED -from Tribler.Core.exceptions import InvalidSignatureException -from Tribler.pyipv8.ipv8.database import database_blob -from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto - -# Metadata, torrents and channel statuses -NEW = 0 -TODELETE = 1 -COMMITTED = 2 -JUST_RECEIVED = 3 -UPDATE_AVAILABLE = 4 -PREVIEW_UPDATE_AVAILABLE = 5 -LEGACY_ENTRY = 6 - -PUBLIC_KEY_LEN = 64 - - -def define_binding(db): - class Metadata(db.Entity): - _discriminator_ = TYPELESS - - # Serializable - metadata_type = orm.Discriminator(int) - # We want to make signature unique=True for safety, but can't do it in Python2 because of Pony bug #390 - signature = orm.Optional(database_blob) - public_key = orm.Optional(database_blob, default='\x00' * PUBLIC_KEY_LEN) - - # Local - rowid = orm.PrimaryKey(int, auto=True) - addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) - status = orm.Optional(int, default=COMMITTED) - - # Special properties - _payload_class = MetadataPayload - _my_key = None - _logger = None - _clock = None - - def __init__(self, *args, **kwargs): - """ - Initialize a metadata object. - - Note: this is a way to manually define Pony entity default attributes in case we really - have to generate the signature before creating the object - from pony.orm.core import DEFAULT - def generate_dict_from_pony_args(cls, **kwargs): - d = {} - for attr in cls._attrs_: - val = kwargs.get(attr.name, DEFAULT) - d[attr.name] = attr.validate(val, entity=cls) - return d - """ - - # Special "sign_with" argument given, sign with it - private_key_override = None - if "sign_with" in kwargs: - kwargs["public_key"] = database_blob(kwargs["sign_with"].pub().key_to_bin()[10:]) - private_key_override = kwargs["sign_with"] - kwargs.pop("sign_with") - - skip_key_check = False - if "skip_key_check" in kwargs and kwargs["skip_key_check"]: - skip_key_check = True - kwargs.pop("skip_key_check") - - # FIXME: potential race condition here? To avoid it, generate the signature _before_ calling "super" - super(Metadata, self).__init__(*args, **kwargs) - - if private_key_override: - self.sign(private_key_override) - return - # No key/signature given, sign with our own key. - elif ("signature" not in kwargs) and \ - (("public_key" not in kwargs) or ( - kwargs["public_key"] == database_blob(self._my_key.pub().key_to_bin()[10:]))): - self.sign(self._my_key) - return - - # Key/signature given, check them for correctness - elif ("public_key" in kwargs) and ("signature" in kwargs) and self.has_valid_signature(): - return - elif skip_key_check: # For getting legacy/test stuff - return - - # Otherwise, something is wrong - raise InvalidSignatureException( - ("Attempted to create %s object with invalid signature/PK: " % str(self.__class__.__name__)) + - (hexlify(self.signature) if self.signature else "empty signature ") + " / " + - (hexlify(self.public_key) if self.public_key else " empty PK")) - - def _serialized(self, key=None): - """ - Serializes the object and returns the result with added signature (tuple output) - :param key: private key to sign object with - :return: (serialized_data, signature) tuple - """ - return self._payload_class(**self.to_dict())._serialized(key) - - def serialized(self, key=None): - """ - Serializes the object and returns the result with added signature (blob output) - :param key: private key to sign object with - :return: serialized_data+signature binary string - """ - return ''.join(self._serialized(key)) - - def _serialized_delete(self): - """ - Create a special command to delete this metadata and encode it for transfer (tuple output). - :return: (serialized_data, signature) tuple - """ - my_dict = Metadata.to_dict(self) - my_dict.update({"metadata_type": DELETED, - "delete_signature": self.signature}) - return DeletedMetadataPayload(**my_dict)._serialized(self._my_key) - - def serialized_delete(self): - """ - Create a special command to delete this metadata and encode it for transfer (blob output). - :return: serialized_data+signature binary string - """ - return ''.join(self._serialized_delete()) - - def to_file(self, filename, key=None): - with open(filename, 'wb') as output_file: - output_file.write(self.serialized(key)) - - def to_delete_file(self, filename): - with open(filename, 'wb') as output_file: - output_file.write(self.serialized_delete()) - - def sign(self, key=None): - if not key: - key = self._my_key - self.public_key = database_blob(key.pub().key_to_bin()[10:]) - _, self.signature = self._serialized(key) - - def has_valid_signature(self): - crypto = default_eccrypto - return (crypto.is_valid_public_bin(b"LibNaCLPK:" + str(self.public_key)) - and self._payload_class(**self.to_dict()).has_valid_signature()) - - @classmethod - def from_payload(cls, payload): - return cls(**payload.to_dict()) - - @classmethod - def from_dict(cls, dct): - return cls(**dct) - - @classmethod - @db_session - def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): - """ - Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based - on a keyword/whether you are subscribed to it. - :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates - the total number of results, regardless the passed first/last parameter. - """ - # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! - pony_query = select(g for g in cls) - - # Filter the results on a keyword or some keywords - if query_filter: - pony_query = cls.search_keyword(query_filter + "*", lim=1000) - - # Sort the query - if sort_by: - sort_expression = "g." + sort_by - sort_expression = sort_expression if sort_asc else desc(sort_expression) - pony_query = pony_query.sort_by(sort_expression) - - return pony_query - - return Metadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 4d50f80e263..4d21734a02c 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -6,7 +6,9 @@ from pony import orm from pony.orm import db_session, raw_sql +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.serialization import TorrentMetadataPayload, REGULAR_TORRENT +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url from Tribler.pyipv8.ipv8.database import database_blob @@ -31,8 +33,14 @@ class TorrentMetadata(db.ChannelNode): def __init__(self, *args, **kwargs): if "health" not in kwargs and "infohash" in kwargs: - ts = db.TorrentState.get(infohash=kwargs["infohash"]) - kwargs["health"] = ts or db.TorrentState(infohash=kwargs["infohash"]) + kwargs["health"] = db.TorrentState.get(infohash=kwargs["infohash"]) or db.TorrentState( + infohash=kwargs["infohash"]) + if 'tracker_info' in kwargs: + sanitized_url = get_uniformed_tracker_url(kwargs["tracker_info"]) + if sanitized_url: + tracker = db.TrackerState.get(url=sanitized_url) or db.TrackerState(url=sanitized_url) + kwargs["health"].trackers.add(tracker) + super(TorrentMetadata, self).__init__(*args, **kwargs) def get_magnet(self): @@ -82,7 +90,8 @@ def get_random_torrents(cls, limit): """ Return some random torrents from the database. """ - return TorrentMetadata.select().where(metadata_type=REGULAR_TORRENT).random(limit) + return TorrentMetadata.select( + lambda g: g.metadata_type == REGULAR_TORRENT and g.status != LEGACY_ENTRY).random(limit) @classmethod @db_session @@ -108,7 +117,7 @@ def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte return pony_query[first - 1:last], total_results @db_session - def to_simple_dict(self, include_status=False, include_trackers=False): + def to_simple_dict(self, include_trackers=False): """ Return a basic dictionary with information about the channel. """ @@ -120,12 +129,10 @@ def to_simple_dict(self, include_status=False, include_trackers=False): "category": self.tags, "num_seeders": self.health.seeders, "num_leechers": self.health.leechers, - "last_tracker_check": self.health.last_check + "last_tracker_check": self.health.last_check, + "status": self.status } - if include_status: - simple_dict['status'] = self.status - if include_trackers: simple_dict['trackers'] = [tracker.url for tracker in self.health.trackers] diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py index be96826ad37..69dc87c9983 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py @@ -9,7 +9,7 @@ class TorrentState(db.Entity): seeders = orm.Optional(int, default=0) leechers = orm.Optional(int, default=0) last_check = orm.Optional(int, size=64, default=0) - metadata = orm.Set('TorrentMetadata') + metadata = orm.Set('TorrentMetadata', reverse='health') trackers = orm.Set('TrackerState', reverse='torrents') return TorrentState diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index b959e01c502..e632f50ded0 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -1,7 +1,6 @@ from __future__ import absolute_import, division import struct -from binascii import hexlify from datetime import datetime, timedelta from Tribler.Core.exceptions import InvalidSignatureException @@ -16,7 +15,6 @@ SIGNATURE_SIZE = 64 EMPTY_SIG = '0' * 64 - # Metadata types. Should have been an enum, but in Python its unwieldy. TYPELESS = 100 CHANNEL_NODE = 200 @@ -74,7 +72,7 @@ def read_payload(data): return read_payload_with_offset(data)[0] -class MetadataPayload(Payload): +class SignedPayload(Payload): """ Payload for metadata. """ @@ -82,11 +80,30 @@ class MetadataPayload(Payload): format_list = ['I', '64s'] def __init__(self, metadata_type, public_key, **kwargs): - super(MetadataPayload, self).__init__() + super(SignedPayload, self).__init__() self.metadata_type = metadata_type self.public_key = str(public_key) self.signature = str(kwargs["signature"]) if "signature" in kwargs else EMPTY_SIG + skip_key_check = kwargs["skip_key_check"] if "skip_key_check" in kwargs else False + + serialized_data = default_serializer.pack_multiple(self.to_pack_list())[0] + if not skip_key_check: + if "key" in kwargs and kwargs["key"]: + key = kwargs["key"] + if self.public_key != str(key.pub().key_to_bin()[10:]): + raise KeysMismatchException(self.public_key, str(key.pub().key_to_bin()[10:])) + + self.signature = default_eccrypto.create_signature(key, serialized_data) + elif "signature" in kwargs: + # This check ensures that an entry with a wrong signature will not proliferate further + if not default_eccrypto.is_valid_signature( + default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + self.public_key), + serialized_data, self.signature): + raise InvalidSignatureException("Tried to create payload with wrong signature") + else: + raise InvalidSignatureException("Tried to create payload without signature") + def has_valid_signature(self): sig_data = default_serializer.pack_multiple(self.to_pack_list())[0] return default_eccrypto.is_valid_signature( @@ -99,8 +116,8 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key): - return MetadataPayload(metadata_type, public_key) + def from_unpack_list(cls, metadata_type, public_key, **kwargs): + return SignedPayload(metadata_type, public_key, **kwargs) @classmethod def from_signed_blob(cls, data, check_signature=True): @@ -108,14 +125,13 @@ def from_signed_blob(cls, data, check_signature=True): @classmethod def from_signed_blob_with_offset(cls, data, check_signature=True, offset=0): + # TODO: stop serializing/deserializing the stuff twice unpack_list, end_offset = default_serializer.unpack_multiple(cls.format_list, data, offset=offset) - payload = cls.from_unpack_list(*unpack_list) if check_signature: - payload.signature = data[end_offset:end_offset + SIGNATURE_SIZE] - data_unsigned = data[offset:end_offset] - key = default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + payload.public_key) - if not default_eccrypto.is_valid_signature(key, data_unsigned, payload.signature): - raise InvalidSignatureException + signature = data[end_offset:end_offset + SIGNATURE_SIZE] + payload = cls.from_unpack_list(*unpack_list, signature=signature) + else: + payload = cls.from_unpack_list(*unpack_list, skip_key_check=True) return payload, end_offset + SIGNATURE_SIZE def to_dict(self): @@ -125,26 +141,12 @@ def to_dict(self): "signature": self.signature } - def _serialized(self, key=None): - # If we are going to sign it, we must provide a matching key - if key and self.public_key != str(key.pub().key_to_bin()[10:]): - raise KeysMismatchException(self.public_key, str(key.pub().key_to_bin()[10:])) - + def _serialized(self): serialized_data = default_serializer.pack_multiple(self.to_pack_list())[0] - if key: - signature = default_eccrypto.create_signature(key, serialized_data) - - # This check ensures that an entry with a wrong signature will not proliferate further - elif default_eccrypto.is_valid_signature(default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + self.public_key), - serialized_data, - self.signature): - signature = self.signature - else: - raise InvalidSignatureException(hexlify(self.signature)) - return str(serialized_data), str(signature) + return str(serialized_data), str(self.signature) - def serialized(self, key=None): - return ''.join(self._serialized(key)) + def serialized(self): + return ''.join(self._serialized()) @classmethod def from_file(cls, filepath): @@ -152,16 +154,16 @@ def from_file(cls, filepath): return cls.from_signed_blob(f.read()) -class ChannelNodePayload(MetadataPayload): - format_list = MetadataPayload.format_list + ['Q'] + ['Q'] +class ChannelNodePayload(SignedPayload): + format_list = SignedPayload.format_list + ['Q', 'Q'] def __init__(self, metadata_type, public_key, id_, origin_id, **kwargs): - super(ChannelNodePayload, self).__init__(metadata_type, public_key, - **kwargs) self.id_ = id_ self.origin_id = origin_id + super(ChannelNodePayload, self).__init__(metadata_type, public_key, + **kwargs) def to_pack_list(self): data = super(ChannelNodePayload, self).to_pack_list() @@ -171,9 +173,11 @@ def to_pack_list(self): @classmethod def from_unpack_list(cls, metadata_type, public_key, - id_, origin_id): + id_, origin_id, + **kwargs): return ChannelNodePayload(metadata_type, public_key, - id_, origin_id) + id_, origin_id, + **kwargs) def to_dict(self): dct = super(ChannelNodePayload, self).to_dict() @@ -194,9 +198,6 @@ def __init__(self, metadata_type, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): - super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, - id_, origin_id, - **kwargs) self.timestamp = timestamp self.infohash = str(infohash) self.size = size @@ -204,6 +205,9 @@ def __init__(self, metadata_type, public_key, self.title = title.decode('utf-8') if type(title) == str else title self.tags = tags.decode('utf-8') if type(tags) == str else tags self.tracker_info = tracker_info.decode('utf-8') if type(tracker_info) == str else tracker_info + super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, + id_, origin_id, + **kwargs) def to_pack_list(self): data = super(TorrentMetadataPayload, self).to_pack_list() @@ -219,10 +223,10 @@ def to_pack_list(self): @classmethod def from_unpack_list(cls, metadata_type, public_key, id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info): + timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): return TorrentMetadataPayload(metadata_type, public_key, id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info) + timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) def to_dict(self): dct = super(TorrentMetadataPayload, self).to_dict() @@ -255,12 +259,11 @@ def __init__(self, metadata_type, public_key, timestamp, infohash, size, torrent_date, title, tags, tracker_info, num_entries, **kwargs): + self.num_entries = num_entries super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, - tracker_info, + timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) - self.num_entries = num_entries def to_pack_list(self): data = super(ChannelMetadataPayload, self).to_pack_list() @@ -271,11 +274,13 @@ def to_pack_list(self): def from_unpack_list(cls, metadata_type, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, - num_entries): + num_entries, + **kwargs): return ChannelMetadataPayload(metadata_type, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, - num_entries) + num_entries, + **kwargs) def to_dict(self): dct = super(ChannelMetadataPayload, self).to_dict() @@ -283,17 +288,18 @@ def to_dict(self): return dct -class DeletedMetadataPayload(MetadataPayload): +class DeletedMetadataPayload(SignedPayload): """ Payload for metadata that stores deleted metadata. """ - format_list = MetadataPayload.format_list + ['64s'] + format_list = SignedPayload.format_list + ['64s'] - def __init__(self, metadata_type, public_key, delete_signature, + def __init__(self, metadata_type, public_key, + delete_signature, **kwargs): + self.delete_signature = str(delete_signature) super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, **kwargs) - self.delete_signature = str(delete_signature) def to_pack_list(self): data = super(DeletedMetadataPayload, self).to_pack_list() @@ -302,9 +308,11 @@ def to_pack_list(self): @classmethod def from_unpack_list(cls, metadata_type, public_key, - delete_signature): + delete_signature, + **kwargs): return DeletedMetadataPayload(metadata_type, public_key, - delete_signature) + delete_signature, + **kwargs) def to_dict(self): dct = super(DeletedMetadataPayload, self).to_dict() diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 4c7c5ab2716..760f50ee183 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -8,8 +8,8 @@ from pony.orm import db_session from Tribler.Core.Category.Category import Category -from Tribler.Core.Modules.MetadataStore.OrmBindings import metadata, torrent_metadata, channel_metadata, channel_node, \ - torrent_state, tracker_state +from Tribler.Core.Modules.MetadataStore.OrmBindings import torrent_metadata, channel_metadata, \ + torrent_state, tracker_state, channel_node from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time @@ -22,32 +22,32 @@ sql_create_fts_table = """ CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING FTS5 - (title, tags, content='Metadata', + (title, tags, content='ChannelNode', tokenize='porter unicode61 remove_diacritics 1');""" sql_add_fts_trigger_insert = """ - CREATE TRIGGER IF NOT EXISTS fts_ai AFTER INSERT ON Metadata + CREATE TRIGGER IF NOT EXISTS fts_ai AFTER INSERT ON ChannelNode BEGIN INSERT INTO FtsIndex(rowid, title, tags) VALUES (new.rowid, new.title, new.tags); END;""" sql_add_fts_trigger_delete = """ - CREATE TRIGGER IF NOT EXISTS fts_ad AFTER DELETE ON Metadata + CREATE TRIGGER IF NOT EXISTS fts_ad AFTER DELETE ON ChannelNode BEGIN DELETE FROM FtsIndex WHERE rowid = old.rowid; END;""" sql_add_fts_trigger_update = """ - CREATE TRIGGER IF NOT EXISTS fts_au AFTER UPDATE ON Metadata BEGIN + CREATE TRIGGER IF NOT EXISTS fts_au AFTER UPDATE ON ChannelNode BEGIN DELETE FROM FtsIndex WHERE rowid = old.rowid; INSERT INTO FtsIndex(rowid, title, tags) VALUES (new.rowid, new.title, new.tags); END;""" -sql_add_signature_index = "CREATE INDEX SignatureIndex ON Metadata(signature);" -sql_add_public_key_index = "CREATE INDEX PublicKeyIndex ON Metadata(public_key);" -sql_add_infohash_index = "CREATE INDEX InfohashIndex ON Metadata(infohash);" +sql_add_signature_index = "CREATE INDEX SignatureIndex ON ChannelNode(signature);" +sql_add_public_key_index = "CREATE INDEX PublicKeyIndex ON ChannelNode(public_key);" +sql_add_infohash_index = "CREATE INDEX InfohashIndex ON ChannelNode(infohash);" class BadChunkException(Exception): @@ -94,14 +94,13 @@ def __init__(self, db_filename, channels_dir, my_key): self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) - self.Metadata = metadata.define_binding(self._db) self.ChannelNode = channel_node.define_binding(self._db) self.TorrentMetadata = torrent_metadata.define_binding(self._db) self.ChannelMetadata = channel_metadata.define_binding(self._db) - self.Metadata._logger = self._logger # Use Store-level logger for every ORM-based class - self.Metadata._my_key = my_key - self.Metadata._clock = self.clock + self.ChannelNode._logger = self._logger # Use Store-level logger for every ORM-based class + self.ChannelNode._my_key = my_key + self.ChannelNode._clock = self.clock self.ChannelMetadata._channels_dir = channels_dir @@ -132,7 +131,6 @@ def process_channel_dir(self, dirname, channel_id): :param channel_id: public_key of the channel. """ # We use multiple separate db_sessions here to limit memory usage when reading big channels - with db_session: channel = self.ChannelMetadata.get(public_key=channel_id) self._logger.debug("Starting processing channel dir %s. Channel %s local/max version %i/%i", @@ -171,7 +169,7 @@ def process_mdblob_file(self, filepath): """ Process a file with metadata in a channel directory. :param filepath: The path to the file - :return Metadata objects list if we can correctly load the metadata + :return ChannelNode objects list if we can correctly load the metadata """ with open(filepath, 'rb') as f: serialized_data = f.read() @@ -199,12 +197,13 @@ def process_squashed_mdblob(self, chunk_data): # Can't use db_session wrapper here, performance drops 10 times! Pony bug! def process_payload(self, payload): with db_session: - if self.Metadata.exists(signature=payload.signature): - return self.Metadata.get(signature=payload.signature) + if self.ChannelNode.exists(signature=payload.signature): + return self.ChannelNode.get(signature=payload.signature) if payload.metadata_type == DELETED: # We only allow people to delete their own entries, thus PKs must match - existing_metadata = self.Metadata.get(signature=payload.delete_signature, public_key=payload.public_key) + existing_metadata = self.ChannelNode.get(signature=payload.delete_signature, + public_key=payload.public_key) if existing_metadata: existing_metadata.delete() return None diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index 4151ebf85ea..a9c610d52c0 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -26,7 +26,7 @@ def start(self): The Metadata Store checks the database at regular intervals to see if new channels are available for preview or subscribed channels require updating. """ - queue_check_interval = 2.0 # seconds + queue_check_interval = 5.0 # seconds self.register_task("Process channels download queue", LoopingCall(self.check_channels_updates)).start(queue_check_interval) @@ -40,15 +40,22 @@ def check_channels_updates(self): """ Check whether there are channels that are updated. If so, download the new version of the channel. """ - with db_session: - channels_queue = list(self.session.lm.mds.ChannelMetadata.get_updated_channels()) - - for channel in channels_queue: - if not self.session.has_download(hexlify(str(channel.infohash))): - self._logger.info("Downloading new channel version %s ver %i->%i", - str(channel.public_key).encode("hex"), - channel.local_version, channel.timestamp) - self.download_channel(channel) + # FIXME: These naughty try-except-pass workarounds are necessary to keep the loop going in all circumstances + try: + with db_session: + channels_queue = list(self.session.lm.mds.ChannelMetadata.get_updated_channels()) + + for channel in channels_queue: + try: + if not self.session.has_download(hexlify(str(channel.infohash))): + self._logger.info("Downloading new channel version %s ver %i->%i", + str(channel.public_key).encode("hex"), + channel.local_version, channel.timestamp) + self.download_channel(channel) + except: + pass + except: + pass def on_channel_download_finished(self, download, channel_id, finished_deferred=None): """ diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index d207eb54f0d..8ceec944345 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -56,7 +56,9 @@ def convert_sort_param_to_pony_col(sort_param): u'size': "size", u'infohash': "infohash", u'date': "torrent_date", - u'status': 'status' + u'status': 'status', + u'torrents': 'num_entries', + u'health': 'health.seeders' } if sort_param not in json2pony_columns: diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index a179d61f4ac..e163fff698d 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -4,6 +4,7 @@ import json import os import sys +import urllib from binascii import unhexlify, hexlify from pony.orm import db_session @@ -11,7 +12,7 @@ from Tribler.Core.Modules.restapi.metadata_endpoint import SpecificChannelTorrentsEndpoint from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.exceptions import DuplicateTorrentFileError, HttpError +from Tribler.Core.exceptions import DuplicateTorrentFileError def chunks(l, n): @@ -63,8 +64,8 @@ def render_POST(self, request): return json.dumps({"error": "your channel has not been created"}) my_channel.update_metadata(update_dict={ - "tags": parameters['name'][0].encode('utf-8'), - "title": parameters['description'][0].encode('utf-8') + "tags": urllib.unquote(parameters['description'][0]).decode('utf-8'), + "title": urllib.unquote(parameters['name'][0]).decode('utf-8') }) return json.dumps({"edited": True}) @@ -79,7 +80,7 @@ def render_PUT(self, request): if 'description' not in parameters or not parameters['description']: description = u'' else: - description = str(parameters['description'][0]).encode('utf-8') + description = urllib.unquote(parameters['description'][0]).decode('utf-8') my_key = self.session.trustchain_keypair my_channel_pk = my_key.pub().key_to_bin() @@ -89,7 +90,7 @@ def render_PUT(self, request): request.setResponseCode(http.CONFLICT) return json.dumps({"error": "channel already exists"}) - title = str(parameters['name'][0]).encode('utf-8') + title = urllib.unquote(parameters['name'][0]).decode('utf-8') self.session.lm.mds.ChannelMetadata.create_channel(title, description) return json.dumps({ "added": str(my_channel_pk).encode("hex"), @@ -114,7 +115,7 @@ def render_GET(self, request): torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( first, last, sort_by, sort_asc, query_filter, channel) - torrents = [torrent.to_simple_dict(include_status=True) for torrent in torrents] + torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ "torrents": torrents, diff --git a/Tribler/Core/TorrentChecker/session.py b/Tribler/Core/TorrentChecker/session.py index a032f5afa3a..cdc676e2a76 100644 --- a/Tribler/Core/TorrentChecker/session.py +++ b/Tribler/Core/TorrentChecker/session.py @@ -4,13 +4,13 @@ import random import socket import struct +import sys import time from abc import ABCMeta, abstractmethod, abstractproperty - +from binascii import hexlify from libtorrent import bdecode from six import text_type - from twisted.internet import defer, reactor from twisted.internet.defer import Deferred, inlineCallbacks from twisted.internet.protocol import DatagramProtocol @@ -108,7 +108,7 @@ def can_add_request(self): :return: True or False. """ - #TODO(ardhi) : quickfix for etree.org can't handle multiple infohash in single call + # TODO(ardhi) : quickfix for etree.org can't handle multiple infohash in single call etree_condition = "etree" not in self.tracker_url return not self._is_initiated and len(self._infohash_list) < MAX_TRACKER_MULTI_SCRAPE and etree_condition @@ -327,7 +327,7 @@ def _process_scrape_response(self, body): leechers = incomplete # Store the information in the dictionary - response_list.append({'infohash': infohash.encode('hex'), 'seeders': seeders, 'leechers': leechers}) + response_list.append({'infohash': hexlify(infohash), 'seeders': seeders, 'leechers': leechers}) # remove this infohash in the infohash list of this session if infohash in unprocessed_infohash_list: @@ -340,7 +340,7 @@ def _process_scrape_response(self, body): # handle the infohashes with no result (seeders/leechers = 0/0) for infohash in unprocessed_infohash_list: - response_list.append({'infohash': infohash.encode('hex'), 'seeders': 0, 'leechers': 0}) + response_list.append({'infohash': hexlify(infohash), 'seeders': 0, 'leechers': 0}) self._is_finished = True if self.result_deferred and not self.result_deferred.called: @@ -597,8 +597,13 @@ def handle_connection_response(self, response): self.generate_transaction_id() # pack and send the message + if sys.version_info.major > 2: + infohash_list = self._infohash_list + else: + infohash_list = [str(infohash) for infohash in self._infohash_list] + fmt = '!qii' + ('20s' * len(self._infohash_list)) - message = struct.pack(fmt, self._connection_id, self.action, self.transaction_id, *self._infohash_list) + message = struct.pack(fmt, self._connection_id, self.action, self.transaction_id, *infohash_list) # Send the scrape message self.socket_mgr.send_request(message, self) @@ -645,7 +650,7 @@ def handle_scrape_response(self, response): # Store the information in the hash dict to be returned. # Sow complete as seeders. "complete: number of peers with the entire file, i.e. seeders (integer)" # - https://wiki.theory.org/BitTorrentSpecification#Tracker_.27scrape.27_Convention - response_list.append({'infohash': infohash.encode('hex'), 'seeders': complete, 'leechers': incomplete}) + response_list.append({'infohash': hexlify(infohash), 'seeders': complete, 'leechers': incomplete}) # close this socket and remove its transaction ID from the list self.remove_transaction_id() @@ -696,8 +701,9 @@ def connect_to_tracker(self): Fakely connects to a tracker. :return: A deferred with a callback containing an empty dictionary. """ + def on_metainfo_received(metainfo): - self.result_deferred.callback({'DHT': [{'infohash': self.infohash.encode('hex'), + self.result_deferred.callback({'DHT': [{'infohash': hexlify(self.infohash), 'seeders': metainfo['seeders'], 'leechers': metainfo['leechers']}]}) def on_metainfo_timeout(_): diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index 9d6adcda5f9..09a7438b144 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -121,7 +121,7 @@ def _task_select_tracker(self): # get the torrents that should be checked infohashes = [] with db_session: - tracker = self.tribler_session.lm.mds.TrackerState[tracker_url] + tracker = self.tribler_session.lm.mds.TrackerState.get(url=tracker_url) if tracker: torrents = tracker.torrents for torrent in torrents: @@ -212,7 +212,7 @@ def add_gui_request(self, infohash, timeout=20, scrape_now=False): :param scrape_now: Flag whether we want to force scraping immediately """ with db_session: - result = self.tribler_session.lm.mds.TorrentState[database_blob(infohash)] + result = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(infohash)) if not result: self._logger.warn(u"torrent info not found, skip. infohash: %s", hexlify(infohash)) return fail(Failure(RuntimeError("Torrent not found"))) @@ -307,7 +307,7 @@ def _update_torrent_result(self, response, update_dict): self._logger.debug(u"Update result %s/%s for %s", seeders, leechers, hexlify(infohash)) with db_session: - result = self.tribler_session.lm.mds.TorrentState[database_blob(infohash)] + result = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(infohash)) for tracker in result.trackers: tracker.last_check = int(time.time()) if update_dict.get(tracker.url, {'seeders': 0, 'leechers': 0}) > 0: diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py index fe7d02a6d2d..a6857653be7 100644 --- a/Tribler/Core/Upgrade/db71_to_pony.py +++ b/Tribler/Core/Upgrade/db71_to_pony.py @@ -1,44 +1,64 @@ import base64 import datetime +import os from binascii import unhexlify import apsw from pony.orm import db_session from six import text_type -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import LEGACY_ENTRY +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.store import MetadataStore -from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url, MalformedTrackerURLException from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto -select_channels_sql = "Select name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ - + "FROM Channels " \ - + "WHERE nr_torrents >= 3 " \ - + "AND name not NULL;" +BATCH_SIZE = 10000 class DispersyToPonyMigration(object): - def __init__(self, tribler_db, dispersy_db, metadata_store): + def __init__(self, tribler_db, metadata_store): self.tribler_db = tribler_db - self.dispersy_db = dispersy_db self.mds = metadata_store + def dispesy_cid_to_pk(self, dispersy_cid): + return database_blob(unhexlify(("%X" % dispersy_cid).zfill(128))) + + def pseudo_signature(self): + return database_blob('\x00' * 32) + + def final_timestamp(self): + return 1 << 62 + + select_channels_sql = "Select id, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ + + "FROM Channels " \ + + "WHERE nr_torrents >= 3 " \ + + "AND name not NULL;" + def get_old_channels(self): connection = apsw.Connection(self.tribler_db) cursor = connection.cursor() channels = [] - for channel_id, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute( - select_channels_sql): - channels.append({"old_id": channel_id, - "title": name, - "public_key": dispersy_cid, - "timestamp": modified, - "version": nr_torrents, - "votes": nr_favorite, - "xxx": nr_spam}) + for id_, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute( + self.select_channels_sql): + if nr_torrents and nr_torrents > 0: + channels.append({"id_": 0, + "infohash": database_blob(os.urandom(20)), + "title": name or '', + "public_key": self.dispesy_cid_to_pk(id_), + "timestamp": self.final_timestamp(), + "votes": int(nr_favorite or 0), + "xxx": float(nr_spam or 0), + "origin_id": 0, + "signature": self.pseudo_signature(), + "skip_key_check": True, + "size": 0, + "local_version": self.final_timestamp(), + "subscribed": False, + "status": LEGACY_ENTRY, + "num_entries": int(nr_torrents or 0)}) return channels select_trackers_sql = "select tracker_id, tracker, last_check, failures, is_alive from TrackerInfo" @@ -62,70 +82,107 @@ def get_old_trackers(self): "is_alive": is_alive}) return trackers - select_torrents_sql = "SELECT ct.channel_id, tracker_id, ct.name, t.infohash, t.length, t.creation_date, t.torrent_id, t.category, t.num_seeders, t.num_leechers, t.last_tracker_check " \ - " FROM _ChannelTorrents ct, Torrent t, TorrentTrackerMapping mp WHERE ct.name NOT NULL and t.length>0 AND t.category NOT NULL AND ct.deleted_at IS NULL " \ - " AND t.torrent_id == ct.torrent_id AND mp.torrent_id == t.torrent_id " + select_torrents_sql = " FROM _ChannelTorrents ct, Torrent t, TorrentTrackerMapping mp, TrackerInfo ti WHERE ct.name NOT NULL and t.length>0 AND t.category NOT NULL AND ct.deleted_at IS NULL " + \ + " AND t.torrent_id == ct.torrent_id AND t.infohash NOT NULL AND mp.torrent_id == t.torrent_id AND ti.tracker_id == mp.tracker_id AND ti.tracker!='DHT' AND ti.tracker!='no-DHT' group by infohash ORDER BY ti.is_alive desc, ti.failures, ti.last_check desc " - def get_old_torrents(self, trackers, chunk_size=10000, offset=0): + def get_old_torrents_count(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + cursor.execute("SELECT COUNT(*) FROM (SELECT t.torrent_id " + self.select_torrents_sql + " )") + return cursor.fetchone()[0] + + def get_old_torrents(self, batch_size=BATCH_SIZE, offset=0): connection = apsw.Connection(self.tribler_db) cursor = connection.cursor() torrents = [] - for channel_id, tracker_id, name, infohash, length, creation_date, torrent_id, category, num_seeders, num_leechers, tracker_url in cursor.execute( - self.select_torrents_sql + " LIMIT " + str(chunk_size) + " OFFSET " + str(offset)): + for tracker_url, channel_id, name, infohash, length, creation_date, torrent_id, category, num_seeders, num_leechers, last_tracker_check in cursor.execute( + "SELECT " + \ + "ti.tracker, ct.channel_id, ct.name, t.infohash, t.length, t.creation_date, t.torrent_id, t.category, t.num_seeders, t.num_leechers, t.last_tracker_check " + \ + self.select_torrents_sql + (" LIMIT " + str(batch_size) + " OFFSET " + str(offset))): # check if name is valid unicode data try: name = text_type(name) except UnicodeDecodeError: continue - # num_seeders - # num_leechers - # last_tracker_check + try: if len(base64.decodestring(infohash)) != 20: continue + infohash = base64.decodestring(infohash) + torrents.append( + ({ + "status": LEGACY_ENTRY, + "infohash": infohash, + "timestamp": int(torrent_id or 0), + "size": int(length or 0), + "torrent_date": datetime.datetime.utcfromtimestamp(creation_date or 0), + "title": name or '', + "tags": category or '', + "id_": torrent_id or 0, + "origin_id": 0, + "tracker_info": tracker_url, + "public_key": self.dispesy_cid_to_pk(channel_id), + "signature": self.pseudo_signature(), + "xxx": int(category == u'xxx'), + "skip_key_check": True}, + { + "seeders": int(num_seeders or 0), + "leechers": int(num_leechers or 0), + "last_check": int(last_tracker_check or 0)})) except: continue - infohash = base64.decodestring(infohash) - - torrents.append({ - "status": LEGACY_ENTRY, - "infohash": infohash, - "timestamp": torrent_id, - "size": length, - "torrent_date": datetime.datetime.utcfromtimestamp(creation_date), - "title": name, - "tags": category, - "id_": torrent_id, - "origin_id": 0, - "tracker_info": trackers[tracker_id]['tracker'] if tracker_id in trackers else "", - "public_key": database_blob(unhexlify(("%X" % channel_id).zfill(128))), - "signature": database_blob('\x00' * 32), - "xxx": int(category == u'xxx'), - "skip_key_check": True}) + return torrents if __name__ == "__main__": my_key = default_eccrypto.generate_key(u"curve25519") - mds = MetadataStore(":memory:", "/tmp", my_key) - d = DispersyToPonyMigration("/tmp/tribler.sdb", "/tmp/dispersy.sdb", mds) + mds = MetadataStore("/tmp/metadata.db", "/tmp", my_key) + d = DispersyToPonyMigration("/tmp/tribler.sdb", mds) # old_channels = d.get_old_channels() old_trackers = d.get_old_trackers() + + start = datetime.datetime.utcnow() + x = 0 + batch_size = 1000 + total_to_convert = d.get_old_torrents_count() + old_torrents = d.get_old_torrents() + + while True: + old_torrents = d.get_old_torrents(batch_size=batch_size, offset=x) + if not old_torrents: + break + with db_session: + for (t, h) in old_torrents: + try: + m = mds.TorrentMetadata(**t) + except MalformedTrackerURLException: + print t + exit(1) + + + if h["last_check"] > 0: + m.health.set(**h) + x += batch_size + print ("%i/%i" % (x, total_to_convert)) + with db_session: - for t in d.get_old_torrents(old_trackers): - mds.TorrentMetadata(**t) - -""" -select Torrent.infohash, Torrent.length, Torrent.name, Torrent.creation_date, ChannelTorrents.torrent_id, Torrent.category -from ChannelTorrents, Channels, Torrent -where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id -select Torrent.infohash, Torrent.num_seeders, Torrent.num_leechers, Torrent.last_tracker_check -from ChannelTorrents, Channels, Torrent -where ChannelTorrents.torrent_id = Torrent.torrent_id AND Channels.id = ChannelTorrents.channel_id - -""" + old_channels = d.get_old_channels() + for c in old_channels: + mds.ChannelMetadata(**c) + + with db_session: + for c in mds.ChannelMetadata.select()[:]: + c.num_entries = c.contents_len + if c.num_entries == 0: + c.delete() + + stop = datetime.datetime.utcnow() + elapsed = (stop-start).total_seconds() + + print ("%i entries converted in %i seconds (%i e/s)" % (total_to_convert, int(elapsed), int(total_to_convert/elapsed))) # 1 - Move Trackers (URLs) # 2 - Move torrent Infohashes diff --git a/Tribler/Core/Utilities/tracker_utils.py b/Tribler/Core/Utilities/tracker_utils.py index 0bfe81fa487..64c4afdd1d6 100644 --- a/Tribler/Core/Utilities/tracker_utils.py +++ b/Tribler/Core/Utilities/tracker_utils.py @@ -92,7 +92,7 @@ def get_uniformed_tracker_url(tracker_url): else: uniformed_path = url.path.rstrip('/') # HTTP trackers must have a path - if url.scheme == 'http' and not url.path: + if url.scheme == 'http' and not uniformed_path: continue if url.scheme == 'http' and uniformed_port == HTTP_PORT: @@ -101,7 +101,8 @@ def get_uniformed_tracker_url(tracker_url): uniformed_url = u'%s://%s:%d%s' % (uniformed_scheme, uniformed_hostname, uniformed_port, uniformed_path) except (UnicodeError, ValueError): continue - return uniformed_url + else: + return uniformed_url return None diff --git a/Tribler/Test/Community/gigachannel/test_community.py b/Tribler/Test/Community/gigachannel/test_community.py index 1b4144d90c2..e799abf7897 100644 --- a/Tribler/Test/Community/gigachannel/test_community.py +++ b/Tribler/Test/Community/gigachannel/test_community.py @@ -3,7 +3,7 @@ from pony.orm import db_session from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.Utilities.random_utils import random_infohash from Tribler.community.gigachannel.community import GigaChannelCommunity diff --git a/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py b/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py index fda97a70409..f266ffb733e 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py +++ b/Tribler/Test/Core/Modules/MetadataStore/gen_test_data.py @@ -4,7 +4,7 @@ from pony.orm import db_session -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef from Tribler.Test.Core.Modules.MetadataStore.test_channel_download import CHANNEL_METADATA, CHANNEL_TORRENT, \ @@ -41,7 +41,7 @@ def gen_sample_channel(mds): t4 = mds.TorrentMetadata.from_dict(gen_random_entry()) my_channel.commit_channel_torrent() - my_channel.delete_torrent_from_channel(t2.infohash) + my_channel.delete_torrent(t2.infohash) my_channel.commit_channel_torrent() # Rename files to stable names diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py index 6c38ae0f45f..93749b7c176 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py @@ -53,4 +53,5 @@ def test_channel_update_and_download(self): # There should be 4 torrents + 1 channel torrent channel2 = self.session.lm.mds.ChannelMetadata.get_channel_with_id(payload.public_key) self.assertEqual(5, len(list(self.session.lm.mds.TorrentMetadata.select()))) - self.assertEqual(12, channel2.local_version) + self.assertEqual(9, channel2.timestamp) + self.assertEqual(channel2.timestamp, channel2.local_version) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index c5ceb8ea7a2..e5527e450df 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -12,7 +12,7 @@ from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import CHANNEL_DIR_NAME_LENGTH, ROOT_CHANNEL_ID, \ entries_to_chunk -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef @@ -83,11 +83,11 @@ def test_list_contents(self): """ Test whether a correct list with channel content is returned from the database """ - self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + self.mds.ChannelNode._my_key = default_eccrypto.generate_key('low') channel1 = self.mds.ChannelMetadata() self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) - self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + self.mds.ChannelNode._my_key = default_eccrypto.generate_key('low') channel2 = self.mds.ChannelMetadata() self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) self.mds.TorrentMetadata.from_dict(dict(self.torrent_template)) @@ -138,9 +138,9 @@ def test_process_channel_metadata_payload(self): # Check that we always take the latest version channel_metadata.timestamp -= 1 - self.assertEqual(channel_metadata.timestamp, 9) + self.assertEqual(channel_metadata.timestamp, 6) channel_metadata = self.mds.ChannelMetadata.process_channel_metadata_payload(payload) - self.assertEqual(channel_metadata.timestamp, 10) + self.assertEqual(channel_metadata.timestamp, 7) self.assertEqual(len(self.mds.ChannelMetadata.select()), 1) @db_session @@ -181,12 +181,11 @@ def test_add_metadata_to_channel(self): Test whether adding new torrents to a channel works as expected """ channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') - self.mds.TorrentMetadata.from_dict( - dict(self.torrent_template, public_key=channel_metadata.public_key, status=NEW)) + self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) channel_metadata.commit_channel_torrent() self.assertEqual(channel_metadata.id_, ROOT_CHANNEL_ID) - self.assertEqual(channel_metadata.timestamp, 3) + self.assertEqual(channel_metadata.timestamp, 2) self.assertEqual(channel_metadata.num_entries, 1) @db_session @@ -261,7 +260,7 @@ def test_get_channels(self): # First we create a few channels for ind in xrange(10): - self.mds.Metadata._my_key = default_eccrypto.generate_key('low') + self.mds.ChannelNode._my_key = default_eccrypto.generate_key('low') _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) channels = self.mds.ChannelMetadata.get_channels(first=1, last=5) self.assertEqual(len(channels[0]), 5) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py index 61f90bc805e..2e2aba83d31 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py @@ -4,15 +4,16 @@ from pony.orm import db_session from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.serialization import MetadataPayload, KeysMismatchException +from Tribler.Core.Modules.MetadataStore.serialization import KeysMismatchException, ChannelNodePayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Test.Core.base_test import TriblerCoreTest +from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto class TestMetadata(TriblerCoreTest): """ - Contains various tests for the Metadata type. + Contains various tests for the ChannelNode type. """ @inlineCallbacks @@ -32,7 +33,7 @@ def test_to_dict(self): """ Test whether converting metadata to a dictionary works """ - metadata = self.mds.Metadata.from_dict({}) + metadata = self.mds.ChannelNode.from_dict({}) self.assertTrue(metadata.to_dict()) @db_session @@ -40,18 +41,19 @@ def test_serialization(self): """ Test converting metadata to serialized data and back """ - metadata1 = self.mds.Metadata.from_dict({}) + metadata1 = self.mds.ChannelNode.from_dict({}) serialized1 = metadata1.serialized() metadata1.delete() + orm.flush() - metadata2 = self.mds.Metadata.from_payload(MetadataPayload.from_signed_blob(serialized1)) + metadata2 = self.mds.ChannelNode.from_payload(ChannelNodePayload.from_signed_blob(serialized1)) serialized2 = metadata2.serialized() self.assertEqual(serialized1, serialized2) @db_session def test_key_mismatch_exception(self): mismatched_key = default_eccrypto.generate_key(u"curve25519") - metadata = self.mds.Metadata.from_dict({}) + metadata = self.mds.ChannelNode.from_dict({}) self.assertRaises(KeysMismatchException, metadata.serialized, key=mismatched_key) @db_session @@ -59,7 +61,7 @@ def test_to_file(self): """ Test writing metadata to a file """ - metadata = self.mds.Metadata.from_dict({}) + metadata = self.mds.ChannelNode.from_dict({}) file_path = os.path.join(self.session_base_dir, 'metadata.file') metadata.to_file(file_path) self.assertTrue(os.path.exists(file_path)) @@ -69,26 +71,31 @@ def test_has_valid_signature(self): """ Test whether a signature can be validated correctly """ - metadata = self.mds.Metadata.from_dict({}) + metadata = self.mds.ChannelNode.from_dict({}) self.assertTrue(metadata.has_valid_signature()) - saved_key = metadata.public_key - # Mess with the public key - metadata.public_key = 'a' - self.assertFalse(metadata.has_valid_signature()) + md_dict = metadata.to_dict() # Mess with the signature - metadata.public_key = saved_key metadata.signature = 'a' self.assertFalse(metadata.has_valid_signature()) + # Create metadata with wrong key + metadata.delete() + md_dict.update(public_key=database_blob("aaa")) + md_dict.pop("rowid") + + metadata = self.mds.ChannelNode(skip_key_check=True, **md_dict) + self.assertFalse(metadata.has_valid_signature()) + @db_session def test_from_payload(self): """ Test converting a metadata payload to a metadata object """ - metadata = self.mds.Metadata.from_dict({}) + metadata = self.mds.ChannelNode.from_dict({}) metadata_dict = metadata.to_dict() metadata.delete() - metadata_payload = MetadataPayload(**metadata_dict) - self.assertTrue(self.mds.Metadata.from_payload(metadata_payload)) + orm.flush() + metadata_payload = ChannelNodePayload(**metadata_dict) + self.assertTrue(self.mds.ChannelNode.from_payload(metadata_payload)) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 5cfb3d40704..93cb0bbe925 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -9,8 +9,8 @@ from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW -from Tribler.Core.Modules.MetadataStore.serialization import (ChannelMetadataPayload, MetadataPayload, +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW +from Tribler.Core.Modules.MetadataStore.serialization import (ChannelMetadataPayload, SignedPayload, UnknownBlobTypeException) from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Test.Core.base_test import TriblerCoreTest @@ -20,9 +20,9 @@ def make_wrong_payload(filename): key = default_eccrypto.generate_key(u"curve25519") - metadata_payload = MetadataPayload(666, database_blob(key.pub().key_to_bin()[10:])) + metadata_payload = SignedPayload(666, database_blob(key.pub().key_to_bin()[10:]), signature='\x00'*64, skip_key_check=True) with open(filename, 'wb') as output_file: - output_file.write(''.join(metadata_payload.serialized(key))) + output_file.write(''.join(metadata_payload.serialized())) class TestMetadataStore(TriblerCoreTest): @@ -120,16 +120,15 @@ def test_process_channel_dir(self): self.assertFalse(channel.contents_list) self.mds.process_channel_dir(self.CHANNEL_DIR, channel.public_key) self.assertEqual(len(channel.contents_list), 3) - self.assertEqual(channel.local_version, 9) + self.assertEqual(channel.timestamp, 7) + self.assertEqual(channel.local_version, channel.timestamp) @db_session def test_get_num_channels_torrents(self): self.mds.ChannelMetadata(title='testchan', id_=0) self.mds.ChannelMetadata(title='testchan', id_=123) - foreign1 = self.mds.ChannelMetadata(title='testchan', id_=0) - foreign1.public_key = unhexlify('1'*20) - foreign2 = self.mds.ChannelMetadata(title='testchan', id_=123) - foreign2.public_key = unhexlify('1'*20) + self.mds.ChannelMetadata(title='testchan', id_=0, public_key=unhexlify('0'*20), signature=unhexlify('0'*64), skip_key_check=True) + self.mds.ChannelMetadata(title='testchan', id_=0, public_key=unhexlify('1'*20), signature=unhexlify('1'*64), skip_key_check=True) md_list = [self.mds.TorrentMetadata(title='test' + str(x), status=NEW) for x in range(0, 3)] diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 4107d9a28c0..b9a2dd71f13 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -4,6 +4,7 @@ import os from datetime import datetime +from pony import orm from pony.orm import db_session from six.moves import xrange from twisted.internet.defer import inlineCallbacks @@ -67,6 +68,7 @@ def test_search_keyword(self): dict(self.torrent_template, title="xoxoxo bar", tags="video")) self.mds.TorrentMetadata.from_dict( dict(self.torrent_template, title="xoxoxo bar", tags="audio")) + orm.flush() # Search for torrents with the keyword 'foo', it should return one result results = self.mds.TorrentMetadata.search_keyword("foo")[:] @@ -167,7 +169,7 @@ def test_get_torrents(self): # First we create a few channels and add some torrents to these channels for ind in xrange(5): - self.mds.Metadata._my_key = default_eccrypto.generate_key('curve25519') + self.mds.ChannelNode._my_key = default_eccrypto.generate_key('curve25519') _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) for torrent_ind in xrange(5): _ = self.mds.TorrentMetadata(title='torrent%d' % torrent_ind) @@ -177,7 +179,7 @@ def test_get_torrents(self): self.assertEqual(torrents[1], 25) # Test fetching torrents in a channel - channel_pk = self.mds.Metadata._my_key.pub().key_to_bin()[10:] + channel_pk = self.mds.ChannelNode._my_key.pub().key_to_bin()[10:] torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=10, sort_by='title', channel_pk=channel_pk) self.assertEqual(len(torrents[0]), 5) self.assertEqual(torrents[1], 5) diff --git a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py index d3de166dd03..e78578bf1da 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py @@ -577,7 +577,7 @@ def verify_download(_): self.assertGreaterEqual(len(self.session.get_downloads()), 1) post_data = {'uri': 'file:%s' % os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')} - expected_json = {'started': True, 'infohash': 'ec98c89912a98ce6561d3d47a77e35ee5388fb88'} + expected_json = {'started': True, 'infohash': '459718a85c45cf6e105da0ebb7a0cd3c518d6a83'} return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, expected_json=expected_json).addCallback(verify_download) diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index d1cb1e8d65e..56460165f51 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -29,7 +29,7 @@ def setUp(self): # Add a few channels with db_session: for ind in xrange(10): - self.session.lm.mds.Metadata._my_key = default_eccrypto.generate_key('curve25519') + self.session.lm.mds.ChannelNode._my_key = default_eccrypto.generate_key('curve25519') _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) for torrent_ind in xrange(5): rand_infohash = random_infohash() @@ -84,7 +84,7 @@ def test_subscribe_missing_parameter(self): Test whether an error is returned if we try to subscribe to a channel with the REST API and missing parameters """ self.should_check_equality = False - channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) + channel_pk = hexlify(self.session.lm.mds.ChannelNode._my_key.pub().key_to_bin()[10:]) return self.do_request('metadata/channels/%s' % channel_pk, expected_code=400, request_type='POST') def test_subscribe_no_channel(self): @@ -101,7 +101,7 @@ def test_subscribe(self): """ self.should_check_equality = False post_params = {'subscribe': '1'} - channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) + channel_pk = hexlify(self.session.lm.mds.ChannelNode._my_key.pub().key_to_bin()[10:]) return self.do_request('metadata/channels/%s' % channel_pk, expected_code=200, request_type='POST', post_data=post_params) @@ -117,7 +117,7 @@ def on_response(response): self.assertEqual(len(json_dict['torrents']), 5) self.should_check_equality = False - channel_pk = hexlify(self.session.lm.mds.Metadata._my_key.pub().key_to_bin()[10:]) + channel_pk = hexlify(self.session.lm.mds.ChannelNode._my_key.pub().key_to_bin()[10:]) return self.do_request('metadata/channels/%s/torrents' % channel_pk, expected_code=200).addCallback(on_response) diff --git a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py index fc9c01815fe..1137e788f34 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py @@ -7,7 +7,7 @@ from six.moves import xrange from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Modules.MetadataStore.OrmBindings.metadata import NEW, TODELETE +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW, TODELETE from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest from Tribler.Test.Core.base_test import MockObject diff --git a/Tribler/Test/Core/Utilities/test_tracker_utils.py b/Tribler/Test/Core/Utilities/test_tracker_utils.py index bfb9ad4dd84..99284da086a 100644 --- a/Tribler/Test/Core/Utilities/test_tracker_utils.py +++ b/Tribler/Test/Core/Utilities/test_tracker_utils.py @@ -50,10 +50,19 @@ def test_uniform_http_default_port_given(self): result = get_uniformed_tracker_url("http://torrent.ubuntu.com:80/announce") self.assertEqual(result, u'http://torrent.ubuntu.com/announce') - def test_uniform_trailing_hex(self): + def test_uniform_trailing_zero_hex(self): result = get_uniformed_tracker_url("udp://tracker.1337x.org:80\x00") + self.assertEqual(result, u'udp://tracker.1337x.org:80') + + def test_uniform_trailing_hex(self): + result = get_uniformed_tracker_url("udp://tracker.1337x.org:80\xff") self.assertIsNone(result) + def test_uniform_bad_urlenc(self): + result = get_uniformed_tracker_url(u'http://btjunkie.org/?do=upload') + self.assertIsNone(result) + + class TestParseTrackerUrl(TriblerCoreTest): """ diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000002.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000002.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..35b45596579fc4815c6f32fc87f7ca1f3013553c GIT binary patch literal 283 zcmV+$0p$J!B25@TK)?(E008kf0003j&3%h!w&@J&`S}axTQAVjFDZ?WRd&e_h)r|L z7XD{H90p>#y&jR4UGtKc`cEo~O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`Y%lo?1hmGbMY~3Wjkq7{1^xfOumDs-8K4$V|t6j203^Ja~uv h>WG~H)Tx5H(4UWLo*>>jhD6$%T*(iPdI10c001CoZ`l9< literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..5ed7e4cd80850511a6a571679016eb63aa66c703 GIT binary patch literal 539 zcmV+$0_6P!B25@TK)?n8008kf0003j&3%h!w&@J&`S}axTQAVjFDZ?WRd&e_h)r|L z7XD{H90p>#y&jR4UGtKc`cEo~$}7 zVt8Cqr*!C-2K!He8b*$61poj54YEK008kf_V*mgE9!_azXml=5W-e4{WiCuj zVQy}3b#7y2C}v=1QUT>KS=hz_@y1KUFtcJ6n}GpwJUp~f9)O|p(dzbfy?yw3-7kU! zd5RgAz0N|K1avM|abSsId?FGFhX4Qo4-Wx27X%6c0RjN=1vxk|FfuVWH8nXfIW#ac zH8?OaHW>gi00lw_0P_d{5Oig8bRcDJbaHthIW#jcIRF3v1$F@e@G<}{Vfj#yA9xBP zi57s*ZFQ_052ox2)V=jj9AN2~X3 zI?4if0000F-2gZf1+f4H0s!&_H843jG&wLhGB!0hHZ?doG%-0e9RMO6RR{nm01@2) zSu-&-F*Du(@G&Xuh~#pLQ79iAX^aH7s`nIv^e~2ElGX`3uYF~$QkYD!K1dYt!7=aw dKCt?l&P+;gtt}p537^!-eo$@@)e8Us003v3!e{^h literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..7e7e99a3c2f5f8c4845c734a0919bf9587c26838 GIT binary patch literal 211 zcmV;^04)CmB25@TK)}QR0Du4h0rbs%i)Xg!4C?v$3+7ud(9tg`jgM7!$q$H4bITU~ zXFnVUV!FK^k(OQal9&2VDvIT3JP)NYs6pkmU(kxK@3tsrU}sVR_q4Lq{_I15|_<7wgf&_Vr8JE4zLYf40E>>}1iD7&q5(x@XyGnZ` zv$YaB#GV{HIqGmoeA49XwP+K#+Gc=Sxu6QgR8E|&k}v1{sQCAztNAZeDKRrpuW}7R NJZ0{Q0uBHG005!1VQc^Z literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..3857dbcf66cf1629f81d5e5567462695b0fe222d GIT binary patch literal 223 zcmV<503iPaB25@TK)}!d008kf0003j&3%h!w&@J&`S}axTQAVjFDZ?WRd&e_h)r|L z7XD{H90p>#y&jR4UGtKc`cEo~(Z7+FV{rt)OEL#7Nz;G%Yjr)%u;E{7xWe)2*7yru>)vl;zz*15r%SAeaXp_W5WC>@_628c0(-NX zVM=2h*YW|`6YF&yJ~CYH6Ks0$`H0}bdu#92NmNO-z3RKUQ{Fp<&r4`||I8MR^X=EY gewC?fKD&|6yxN@q>gNXLxq`-zas+PncC6$E0F;|hdH?_b delta 200 zcmaFE_=d5bfq`+tqaxw?uN9jLID$6&^nANqkoRc+`nYyBq1pvEB@QvOvJ07(v|hb! zd61o-zqs&>|KiiDc~n-J=`K02W!VDF2YdfBUZ8A8 ho~WcX)Xciy`zTY>s(8zv-l>{buTIKUH~x2u6##m}P6Yq} diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index 8eee9f30405..025cd9c434c 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1547048874e4:infod5:filesld6:lengthi541e4:pathl23:000000000007.mdblob.lz4eed6:lengthi211e4:pathl23:000000000009.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:e272139feb2182700852b34c8cf6d37012:piece lengthi16384e6:pieces20:’Ç[–jššÁ6ÿ^}ÖfeçÆ_,[ee \ No newline at end of file +d13:creation datei1547808014e4:infod5:filesld6:lengthi539e4:pathl23:000000000006.mdblob.lz4eed6:lengthi211e4:pathl23:000000000007.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:cd7d8b67b6e90ceaf9f90be65b2fd0d112:piece lengthi16384e6:pieces20:ÇŠIivçú‹Ox1v¶}øÕ9ee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index 31f66e52f0b24031d29299dc8f901738a669c4ee..d5cfbfc5dad459b7e75d6d028d4a6cb1694cc643 100644 GIT binary patch delta 200 zcmaFE_=d5bfq`+t+1l>(Z7+FV{rt)OEL#7Nz;G%Yjr)%u;E{7xWe)2*7yru>)vl;zz#Ui2?=j4UGH&yg`vz0<%~w|?N4^d z!j#51uH^%=C)VpagcKZi4_UkP&V*AZFD#VF_;kK1{oiD@_;0%_n`KU=UD5u`G5<>R hpD71AcKd!=R@z#y^m_NZ=N1l0|FbPGEK#|~2>{;jRNnvq delta 200 zcmaFE_=d5bfq`+tqaxw?uN9jLID$6&^nANqkoRc+`nYyBq1pvEB@QvOvJ07(v|hb! zd61o-zqs&>|KiiDc~n-J=`K02W!VDF2YdfBUP4DH(NCRQr)-PKHE3UJ#gvwfzs+Z-~a9jluQ-dB5hJyHRU!>I`7}3u*81W^(_wrWHcQ*qg71wnEmh=02vW05jP}!TZsNbMUWWz}eW2MG z!UF{d5;b&%)!RA22M-T(am-Jn%c~3|sx~d8z_g~IDd4^T000625dj7O2Lb@{RQ!q+ zqWbc4O}ib%_pjVso9gQySpWb407RI80982Zod5s;9(7`MZgh1mF*PnQG%aLhb8B>O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`Y3-%z8j)d)p3`a(5>8lP-Dz_(IuZotbPp%;Ei+^C(w-K&4`I hjo|_jhe>tDqLnA%N0g7@2Nf2Y_E~O>j|2b!003QsZ8ZP@ diff --git a/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000007.mdblob.lz4 deleted file mode 100644 index 9c033fcd50adee2b1deb542732918e3c095f604a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 541 zcmZQk@|AFKIKalp!0^$Wfq_xyQIYWc*NROA96_6XdcIvQ$a}PZeOx=6Q0;=75{H;s z*@a9?TCd)=Jjl+^UtD;`fAQ(nJSwZqbeA01vTT9ogT4P57?>CYS=boZnHWASnef&! zsUjw5dC80EY`^^*rQG^*SQ!`?csDsPFa(HCN(Qp!{4&!sO7#5G^g=RH^?WkRGV{{) zTv8KL;tdTf3<`iMSi>??QuBcfm5h>-0xNy}q!L5D#G>Sk%(7Iy{GxO#GfOi|{lvVy z{L;MSRE3SUw#ALR{T(lIFg;V+cV1cKs=_X#)gQL}=adWgwl{rpPya@8TSj*$`}IjH zuY_`~mzgLtmBHT5@w!h&gus86E}%vH{EQajtXzzYObj1c4UG(pEe%Z!%}s!`v9Y4~BfT1@0Uv4_6#~DxY7(q`JW4RpBNVhFj%F7wq!2O#9r|eB%0~4Xcm-tvc+M cu+sZ_!wmT<%ipuhW7Ms(+8O@KgQAH60Gmy{i~s-t diff --git a/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/e272139feb2182700852b34c8cf6d370/000000000009.mdblob.lz4 deleted file mode 100644 index 9d53ab5d2cd995bc15db088b18b481063fc318c5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 211 zcmV;^04)CmB25@TK)}QR0Du4h0rcW>6QAoLf^Y~@vrLTk({OI$zpq}01`>Us*%-nD z1qTu}bcNO1Il%`H4|8$MPovAL3?!;HEu_G-rl2X{z5gJwJw0=QyH7#U2mmh=02vW05jP}!TZsNbMUWWz}eW2MG z!UF{d5;b&%)!RA22M-T(am-Jn%c~3|sx~d8z_g~IDd4^T000625eo(Y2Lb@{1u--- zHZwUiHZwFaH#jshF*YzUIUfKb5Df?b@K68{bY*jNAZ2cJa(N&*H#Rdc00005c4=f~ zZvX%Q0R8ExcWe^F_}C`tVBb{+I#);awV%%lSJDKA{%Sq%Pmw>TtOaV}%g2`F&nP<0 Y=K2OTRjo`i5YcJjGAb@g0RR9108BMd+yDRo diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index 0012487a23e..445e8ce5d97 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -1,6 +1,9 @@ +from binascii import unhexlify + from pony.orm import db_session from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.exceptions import InvalidSignatureException from Tribler.pyipv8.ipv8.community import Community from Tribler.pyipv8.ipv8.lazy_community import PacketDecodingError from Tribler.pyipv8.ipv8.messaging.payload_headers import BinMemberAuthenticationPayload @@ -12,10 +15,11 @@ class GigaChannelCommunity(Community): Community to gossip around gigachannels. """ - master_peer = Peer("3081a7301006072a8648ce3d020106052b81040027038192000400118911f5102bac4fca2d6ee5c3cb41978a4b657" - "e9707ce2031685c7face02bb3bf42b74a47c1d2c5f936ea2fa2324af12de216abffe01f10f97680e8fe548b82dedf" - "362eb29d3b074187bcfbce6869acb35d8bcef3bb8713c9e9c3b3329f59ff3546c3cd560518f03009ca57895a5421b" - "4afc5b90a59d2096b43eb22becfacded111e84d605a01e91a600e2b55a79d".decode('hex')) + master_peer = Peer(unhexlify("3081a7301006072a8648ce3d020106052b8104002703819200040448a078b597b62d3761a061872cd86" + "10f58cb513f1dc21e66dd59f1e01d582f633b182d9ca6e5859a9a34e61eb77b768e5e9202f642fd50c6" + "0b89d8d8b0bdc355cdf8caac262f6707c80da00b1bcbe7bf91ed5015e5163a76a2b2e630afac96925f5" + "daa8556605043c6da4db7d26113cba9f9cbe63fddf74625117598317e05cb5b8cbd606d0911683570ad" + "bb921c91")) def __init__(self, my_peer, endpoint, network, metadata_store): super(GigaChannelCommunity, self).__init__(my_peer, endpoint, network) @@ -40,11 +44,11 @@ def send_random_to(self, peer): # Choose some random entries and try to pack them into maximum_payload_size bytes md_list = [] with db_session: - channel_l = self.metadata_store.ChannelMetadata.get_random_channels(1)[:] + # TODO: when the health table will be there, send popular torrents instead + channel_l = self.metadata_store.ChannelMetadata.get_random_channels(1, subscribed=True)[:] if not channel_l: return channel = channel_l[0] - # TODO: when the health table will be there, send popular torrents instead md_list.append(channel) md_list.extend(list(channel.get_random_torrents(max_entries - 1))) blob = entries_to_chunk(md_list, maximum_payload_size)[0] if md_list else None @@ -75,7 +79,8 @@ class GigaChannelTestnetCommunity(GigaChannelCommunity): """ This community defines a testnet for the giga channels, used for testing purposes. """ - master_peer = Peer("3081a7301006072a8648ce3d020106052b8104002703819200040726f5b6558151e1b82c3d30c08175c446f5f696b" - "e9b005ee23050fe55f7e4f73c1b84bf30eb0a254c350705f89369ba2c6b6795a50f0aa562b3095bfa8aa069747221" - "c0fb92e207052b7d03fa8a76e0b236d74ac650de37e5dfa02cbd6b9fe2146147f3555bfa7410b9c499a8ec49a80ac" - "84b433fb2bf1740a15e96a5bad2b90b0488bdc791633ee7d829dcd583ee5f".decode('hex')) + master_peer = Peer(unhexlify("3081a7301006072a8648ce3d020106052b81040027038192000401b9f303778e7727b35a4c26487481f" + "a7011e252cc4a6f885f3756bd8898c9620cf1c32e79dd5e75ae277a56702a47428ce47676d005e262fa" + "fd1a131a2cb66be744d52cb1e0fca503658cb3368e9ebe232e7b8c01e3172ebfdb0620b316467e5b2c4" + "c6809565cf2142e8d4322f66a3d13a8c4bb18059c9ed97975a97716a085a93e3e62b0387e63f0bf389a" + "0e9bffe6")) diff --git a/TriblerGUI/tribler_app.py b/TriblerGUI/tribler_app.py index c77cfae5f66..a9c03ebc145 100644 --- a/TriblerGUI/tribler_app.py +++ b/TriblerGUI/tribler_app.py @@ -2,6 +2,7 @@ import sys from PyQt5.QtCore import QEvent +from PyQt5.QtWidgets import QApplication from TriblerGUI.code_executor import CodeExecutor from TriblerGUI.single_application import QtSingleApplication @@ -11,8 +12,8 @@ class TriblerApplication(QtSingleApplication): """ This class represents the main Tribler application. """ - def __init__(self, app_name, args): - QtSingleApplication.__init__(self, app_name, args) + def __init__(self, qpp_name, args): + QApplication.__init__(self, args) self.code_executor = None self.messageReceived.connect(self.on_app_message) diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index 321265c2f79..b117b864ed7 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -113,8 +113,9 @@ def on_create_channel_button_pressed(self): self.window().create_channel_button.setEnabled(False) self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_created, - data=(u'name=%s&description=%s' % - (channel_name, channel_description)).encode('utf-8'), + data=urllib.urlencode({u'name': channel_name.encode('utf-8'), + u'description': channel_description.encode( + 'utf-8')}), method='PUT') def on_channel_created(self, result): @@ -130,8 +131,9 @@ def on_edit_channel_save_button_pressed(self): self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_edited, - data=(u'name=%s&description=%s' % - (channel_name, channel_description)).encode('utf-8'), + data=urllib.urlencode({u'name': channel_name.encode('utf-8'), + u'description': channel_description.encode( + 'utf-8')}), method='POST') def on_channel_edited(self, result): diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 32a803e9194..092d1768dd4 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -27,7 +27,7 @@ class TriblerContentTableView(LazyTableView): def __init__(self, parent=None): LazyTableView.__init__(self, parent) - + self.delegate = None self.setMouseTracking(True) def mouseMoveEvent(self, event): @@ -38,70 +38,12 @@ def redraw(self): self.viewport().update() -class SearchResultsTableView(TriblerContentTableView): - """ - This table displays search results, which can be both torrents and channels. - """ - on_torrent_clicked = pyqtSignal(QModelIndex, dict) - on_channel_clicked = pyqtSignal(dict) - - def __init__(self, parent=None): - TriblerContentTableView.__init__(self, parent) - - self.delegate = SearchResultsDelegate() - self.setItemDelegate(self.delegate) - self.mouse_moved.connect(self.delegate.on_mouse_moved) - self.delegate.redraw_required.connect(self.redraw) - - self.clicked.connect(self.on_table_item_clicked) - - def on_table_item_clicked(self, item): - content_info = self.model().data_items[item.row()] - if content_info['type'] == 'channel': - self.window().channel_page.initialize_with_channel(content_info) - self.window().navigation_stack.append(self.window().stackedWidget.currentIndex()) - self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) - self.on_channel_clicked.emit(content_info) - else: - self.on_torrent_clicked.emit(item, content_info) - - def resizeEvent(self, _): - self.setColumnWidth(0, 100) - self.setColumnWidth(2, 100) - self.setColumnWidth(3, 100) - self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear - - -class TorrentsTableView(TriblerContentTableView): - """ - This table displays various torrents. - """ - on_torrent_clicked = pyqtSignal(QModelIndex, dict) - - def __init__(self, parent=None): - TriblerContentTableView.__init__(self, parent) - - self.delegate = TorrentsButtonsDelegate() - self.setItemDelegate(self.delegate) - self.mouse_moved.connect(self.delegate.on_mouse_moved) - self.delegate.redraw_required.connect(self.redraw) - - self.delegate.play_button.clicked.connect(self.on_play_button_clicked) - self.delegate.download_button.clicked.connect(self.on_download_button_clicked) - self.delegate.commit_control.clicked.connect(self.on_commit_control_clicked) - - self.clicked.connect(self.on_table_item_clicked) - - def on_table_item_clicked(self, item): - if (ACTION_BUTTONS in self.model().column_position and - item.column() == self.model().column_position[ACTION_BUTTONS]) or \ - (u'status' in self.model().column_position and - item.column() == self.model().column_position[u'status']): - return +class DownloadButtonMixin(TriblerContentTableView): + def on_download_button_clicked(self, index): + self.window().start_download_from_uri(index2uri(index)) - torrent_info = self.model().data_items[item.row()] - self.on_torrent_clicked.emit(item, torrent_info) +class PlayButtonMixin(TriblerContentTableView): def on_play_button_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] @@ -119,9 +61,43 @@ def on_play_request_done(_): self.window().tribler_settings['download_defaults']['saveas'], [], 0, callback=on_play_request_done) - def on_download_button_clicked(self, index): - self.window().start_download_from_uri(index2uri(index)) +class SubscribeButtonMixin(TriblerContentTableView): + def on_subscribe_control_clicked(self, index): + if index.model().data_items[index.row()][u'status'] == 6: # LEGACY ENTRIES! + return + if index.model().data_items[index.row()][u'my_channel']: + return + status = int(index.model().data_items[index.row()][u'subscribed']) + public_key = index.model().data_items[index.row()][u'public_key'] + request_mgr = TriblerRequestManager() + request_mgr.perform_request("metadata/channels/%s" % public_key, + lambda _: self.on_unsubscribed_channel.emit(index) if status else + lambda _: self.on_subscribed_channel.emit(index), + data='subscribe=%i' % int(not status), method='POST') + index.model().data_items[index.row()][u'subscribed'] = int(not status) + + +class ItemClickedMixin(TriblerContentTableView): + def on_table_item_clicked(self, item): + column_position = self.model().column_position + if (ACTION_BUTTONS in column_position and item.column() == column_position[ACTION_BUTTONS]) or \ + (u'status' in column_position and item.column() == column_position[u'status']) or \ + (u'subscribed' in column_position and item.column() == column_position[u'subscribed']): + return + + content_info = self.model().data_items[item.row()] + # Safely determine if the thing is a channel. A little bit hackish + if 'torrents' in content_info: + self.window().channel_page.initialize_with_channel(content_info) + self.window().navigation_stack.append(self.window().stackedWidget.currentIndex()) + self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) + self.on_channel_clicked.emit(content_info) + else: + self.on_torrent_clicked.emit(item, content_info) + + +class CommitControlMixin(TriblerContentTableView): def on_commit_control_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] status = index.model().data_items[index.row()][u'status'] @@ -145,6 +121,57 @@ def on_torrent_status_updated(self, json_result, index): self.window().edit_channel_page.channel_dirty = json_result['dirty'] self.window().edit_channel_page.update_channel_commit_views() + +class SearchResultsTableView(ItemClickedMixin, DownloadButtonMixin, PlayButtonMixin, SubscribeButtonMixin, + TriblerContentTableView): + """ + This table displays search results, which can be both torrents and channels. + """ + on_torrent_clicked = pyqtSignal(QModelIndex, dict) + on_channel_clicked = pyqtSignal(dict) + + def __init__(self, parent=None): + TriblerContentTableView.__init__(self, parent) + self.delegate = SearchResultsDelegate() + + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) + + # Mix-in connects + self.clicked.connect(self.on_table_item_clicked) + self.delegate.play_button.clicked.connect(self.on_play_button_clicked) + self.delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) + self.delegate.download_button.clicked.connect(self.on_download_button_clicked) + + def resizeEvent(self, _): + self.setColumnWidth(0, 100) + self.setColumnWidth(2, 100) + self.setColumnWidth(3, 100) + self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear + + +class TorrentsTableView(ItemClickedMixin, CommitControlMixin, DownloadButtonMixin, PlayButtonMixin, + TriblerContentTableView): + """ + This table displays various torrents. + """ + on_torrent_clicked = pyqtSignal(QModelIndex, dict) + + def __init__(self, parent=None): + TriblerContentTableView.__init__(self, parent) + self.delegate = TorrentsButtonsDelegate() + + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) + + # Mix-in connects + self.clicked.connect(self.on_table_item_clicked) + self.delegate.play_button.clicked.connect(self.on_play_button_clicked) + self.delegate.commit_control.clicked.connect(self.on_commit_control_clicked) + self.delegate.download_button.clicked.connect(self.on_download_button_clicked) + def resizeEvent(self, _): if isinstance(self.model(), MyTorrentsContentModel): self.setColumnWidth(0, 100) @@ -159,7 +186,8 @@ def resizeEvent(self, _): self.setColumnWidth(1, self.width() - 404) # Few pixels offset so the horizontal scrollbar does not appear -class ChannelsTableView(TriblerContentTableView): +class ChannelsTableView(ItemClickedMixin, SubscribeButtonMixin, + TriblerContentTableView): """ This table displays various channels. """ @@ -169,47 +197,14 @@ class ChannelsTableView(TriblerContentTableView): def __init__(self, parent=None): TriblerContentTableView.__init__(self, parent) + self.delegate = ChannelsButtonsDelegate() + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) - delegate = ChannelsButtonsDelegate() - self.setItemDelegate(delegate) - self.mouse_moved.connect(delegate.on_mouse_moved) - delegate.redraw_required.connect(self.redraw) - delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) - + # Mix-in connects self.clicked.connect(self.on_table_item_clicked) - - def on_subscribe_control_clicked(self, index): - status = int(index.model().data_items[index.row()][u'subscribed']) - if status: - self.on_unsubscribe_button_clicked(index) - else: - self.on_subscribe_button_clicked(index) - index.model().data_items[index.row()][u'subscribed'] = int(not status) - - def on_subscribe_button_clicked(self, index): - public_key = index.model().data_items[index.row()][u'public_key'] - request_mgr = TriblerRequestManager() - request_mgr.perform_request("metadata/channels/%s" % public_key, - lambda _: self.on_subscribed_channel.emit(index), - data='subscribe=1', method='POST') - - def on_unsubscribe_button_clicked(self, index): - public_key = index.model().data_items[index.row()][u'public_key'] - request_mgr = TriblerRequestManager() - request_mgr.perform_request("metadata/channels/%s" % public_key, - lambda _: self.on_unsubscribed_channel.emit(index), - data='subscribe=0', method='POST') - - def on_table_item_clicked(self, item): - if item.column() == self.model().column_position[u'subscribed']: - return - - channel_info = self.model().data_items[item.row()] - self.window().channel_page.initialize_with_channel(channel_info) - self.window().navigation_stack.append(self.window().stackedWidget.currentIndex()) - self.window().stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) - - self.on_channel_clicked.emit(channel_info) + self.delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) def resizeEvent(self, _): self.setColumnWidth(1, 150) diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py index 74857133e91..73f10f19647 100644 --- a/TriblerGUI/widgets/tablecontentdelegate.py +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -100,12 +100,12 @@ class SearchResultsDelegate(TriblerButtonsDelegate): def __init__(self, parent=None): TriblerButtonsDelegate.__init__(self, parent) self.subscribe_control = SubscribeToggleControl(ACTION_BUTTONS) - self.controls = [self.subscribe_control] self.health_status_widget = HealthStatusDisplay() self.play_button = PlayIconButton() self.download_button = DownloadIconButton() self.ondemand_container = [self.play_button, self.download_button] + self.controls = [self.play_button, self.download_button, self.subscribe_control] def paint_exact(self, painter, option, index): data_item = index.model().data_items[index.row()] @@ -177,6 +177,11 @@ def paint_exact(self, painter, option, index): # Draw empty cell as the background self.paint_empty_background(painter, option) + if index.model().data_items[index.row()][u'status'] == 6: # LEGACY ENTRIES! + return True + if index.model().data_items[index.row()][u'my_channel']: + return True + data_item = index.model().data_items[index.row()] if index == self.hover_index: diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index b3733e7a463..a24188d64fa 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -1,12 +1,10 @@ from __future__ import absolute_import -import time from abc import abstractmethod from PyQt5.QtCore import QAbstractTableModel, QModelIndex, Qt, pyqtSignal -from TriblerGUI.defs import ACTION_BUTTONS, HEALTH_CHECKING, HEALTH_DEAD, HEALTH_ERROR, HEALTH_GOOD, HEALTH_MOOT -from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.defs import ACTION_BUTTONS from TriblerGUI.utilities import format_size diff --git a/TriblerGUI/widgets/torrentdetailstabwidget.py b/TriblerGUI/widgets/torrentdetailstabwidget.py index d190d7673fb..6ff282a2579 100644 --- a/TriblerGUI/widgets/torrentdetailstabwidget.py +++ b/TriblerGUI/widgets/torrentdetailstabwidget.py @@ -3,7 +3,7 @@ import logging import time -from PyQt5.QtCore import pyqtSignal, QModelIndex +from PyQt5.QtCore import QModelIndex from PyQt5.QtWidgets import QLabel, QTabWidget, QTreeWidget, QTreeWidgetItem from TriblerGUI.defs import HEALTH_CHECKING, HEALTH_UNCHECKED, HEALTH_GOOD, HEALTH_MOOT @@ -121,7 +121,15 @@ def on_cancel_health_check(): self.is_health_checking = False if u'health' in self.index.model().column_position: - self.index.model().data_items[self.index.row()][u'health'] = HEALTH_CHECKING + # TODO: DRY this copypaste! + # Check if details widget is still showing the same entry and the entry still exists in the table + try: + data_item = self.index.model().data_items[self.index.row()] + except IndexError: + return + if self.torrent_info["infohash"] != data_item[u'infohash']: + return + data_item[u'health'] = HEALTH_CHECKING index = self.index.model().index(self.index.row(), self.index.model().column_position[u'health']) self.index.model().dataChanged.emit(index, index, []) @@ -149,7 +157,14 @@ def on_health_response(self, response): self.update_torrent_health(total_seeders, total_leechers) def update_torrent_health(self, seeders, leechers): - data_item = self.index.model().data_items[self.index.row()] + # Check if details widget is still showing the same entry and the entry still exists in the table + try: + data_item = self.index.model().data_items[self.index.row()] + except IndexError: + return + if self.torrent_info["infohash"] != data_item[u'infohash']: + return + data_item[u'num_seeders'] = seeders data_item[u'num_leechers'] = leechers data_item[u'last_tracker_check'] = time.time() diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index 8ac0e33e10e..a88d56547db 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -63,7 +63,7 @@ def _on_selection_changed(self, _): def _on_view_sort(self, column, ascending): self.model.reset() - self.load_search_results(1, 50) + self.load_search_results(self.query, 1, 50) def _on_list_scroll(self, event): if self.table_view.verticalScrollBar().value() == self.table_view.verticalScrollBar().maximum() and \ @@ -76,17 +76,18 @@ def load_search_results(self, query, start=None, end=None): """ self.query = query - if not start and not end: + if not start or not end: start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch sort_by, sort_asc = self._get_sort_parameters() - self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request( - "search?q=%s&first=%i&last=%i" % (query, start, end) - + ('&sort_by=%s' % sort_by) - + ('&sort_asc=%d' % sort_asc) - + ('&type=%s' % ('' if not self.model.type_filter else self.model.type_filter)), + ("search?q=%s" % query) + + (("&first=%i" % start) if start else '') + + (("&last=%i" % end) if end else '') + + (('&sort_by=%s' % sort_by) if sort_by else '') + + (('&sort_asc=%d' % sort_asc) if sort_asc else '') + + (('&type=%s' % self.model.type_filter) if self.model.type_filter else ''), self.on_search_results) def on_search_results(self, response): @@ -225,7 +226,7 @@ def load_torrents(self, start=None, end=None): def on_torrents(self, response): if not response: - return + return None self.model.total_items = response['total'] @@ -234,6 +235,7 @@ def on_torrents(self, response): if response['first'] >= self.model.rowCount(): self.model.add_items(response['torrents']) + return True class MyTorrentsTableViewController(TorrentsTableViewController): @@ -264,16 +266,6 @@ def load_torrents(self, start=None, end=None): self.on_torrents) def on_torrents(self, response): - if not response: - return - - self.model.total_items = response['total'] - - if self.num_torrents_label: - self.num_torrents_label.setText("%d items" % response['total']) - - if response['first'] >= self.model.rowCount(): - self.model.add_items(response['torrents']) - - self.table_view.window().edit_channel_page.channel_dirty = response['dirty'] - self.table_view.window().edit_channel_page.update_channel_commit_views() + if super(MyTorrentsTableViewController, self).on_torrents(response): + self.table_view.window().edit_channel_page.channel_dirty = response['dirty'] + self.table_view.window().edit_channel_page.update_channel_commit_views() diff --git a/run_tribler.py b/run_tribler.py index 0639e23c769..195ea6adaf7 100644 --- a/run_tribler.py +++ b/run_tribler.py @@ -1,4 +1,5 @@ import os +import random import sys import logging.config @@ -10,6 +11,8 @@ from check_os import check_environment, check_free_space, error_and_exit, setup_gui_logging, \ should_kill_other_tribler_instances, enable_fault_handler, set_process_priority, \ check_and_enable_code_tracing +api_port = random.randint(10000,20000) + # https://github.com/Tribler/tribler/issues/3702 # We need to make sure that anyone running cp65001 can print to the stdout before we print anything. @@ -68,6 +71,7 @@ def start_tribler(): priority_order = config.get_cpu_priority_order() set_process_priority(pid=os.getpid(), priority_order=priority_order) + print api_port config.set_http_api_port(int(api_port)) config.set_http_api_enabled(True) @@ -115,15 +119,8 @@ def start_tribler(): app = TriblerApplication("triblerapp", sys.argv) - if app.is_running(): - for arg in sys.argv[1:]: - if os.path.exists(arg) and arg.endswith(".torrent"): - app.send_message("file:%s" % arg) - elif arg.startswith('magnet'): - app.send_message(arg) - sys.exit(1) - window = TriblerWindow() + window = TriblerWindow(api_port=api_port) window.setWindowTitle("Tribler") app.set_activation_window(window) app.parse_sys_args(sys.argv) From 23e0fc13a68f0ca2b1abce1d0fccc7113c3d0df4 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Tue, 22 Jan 2019 18:49:49 +0100 Subject: [PATCH 13/38] add unique constraints on db --- .../OrmBindings/channel_metadata.py | 5 +- .../MetadataStore/OrmBindings/channel_node.py | 5 +- .../OrmBindings/torrent_state.py | 3 +- .../OrmBindings/tracker_state.py | 3 +- Tribler/Core/Modules/tracker_manager.py | 9 +- .../Core/TorrentChecker/torrent_checker.py | 41 ++--- Tribler/Core/Upgrade/db71_to_pony.py | 15 +- .../Test/Core/Utilities/test_tracker_utils.py | 4 + TriblerGUI/qt_resources/mainwindow.ui | 171 +++++++++++------- TriblerGUI/widgets/editchannelpage.py | 3 +- TriblerGUI/widgets/lazytableview.py | 46 ++--- 11 files changed, 171 insertions(+), 134 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index cdcea3842cc..b3939878e41 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -249,7 +249,7 @@ def add_torrent_to_channel(self, tdef, extra_info): "tags": tags, "size": tdef.get_length(), "torrent_date": datetime.fromtimestamp(tdef.get_creation_date()), - "tracker_info": get_uniformed_tracker_url(tdef.get_tracker()) or '', + "tracker_info": get_uniformed_tracker_url(tdef.get_tracker() or '') or '', "status": NEW }) torrent_metadata.parents.add(self) @@ -380,7 +380,8 @@ def get_random_channels(cls, limit, subscribed=False): :return: the subset of random channels we are subscribed to :rtype: list """ - return db.ChannelMetadata.select(lambda g: g.subscribed == subscribed and g.status != LEGACY_ENTRY).random( + return db.ChannelMetadata.select( + lambda g: g.subscribed == subscribed and g.status != LEGACY_ENTRY and g.num_entries > 0).random( limit) @db_session diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index 755040ff943..a21fcdbaed1 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -44,7 +44,7 @@ def define_binding(db): class ChannelNode(db.Entity): _discriminator_ = CHANNEL_NODE - rowid = orm.PrimaryKey(int, auto=True) + rowid = orm.PrimaryKey(int, size=64, auto=True) # Serializable metadata_type = orm.Discriminator(int) @@ -52,10 +52,9 @@ class ChannelNode(db.Entity): public_key = orm.Required(database_blob) id_ = orm.Required(int, size=64) - # orm.composite_key(public_key, id_) # Requires Pony 0.7.7+ with Python2 orm.composite_index(public_key, id_) # Requires Pony 0.7.7+ with Python2 - signature = orm.Required(database_blob) + signature = orm.Required(database_blob, unique=True) # Local addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py index 69dc87c9983..1f41e883684 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_state.py @@ -5,7 +5,8 @@ def define_binding(db): class TorrentState(db.Entity): - infohash = orm.PrimaryKey(database_blob) + rowid = orm.PrimaryKey(int, auto=True) + infohash = orm.Required(database_blob, unique=True) seeders = orm.Optional(int, default=0) leechers = orm.Optional(int, default=0) last_check = orm.Optional(int, size=64, default=0) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py index 1d838c561af..5165e45311c 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/tracker_state.py @@ -5,7 +5,8 @@ def define_binding(db): class TrackerState(db.Entity): - url = orm.PrimaryKey(str) + rowid = orm.PrimaryKey(int, auto=True) + url = orm.Required(str, unique=True) last_check = orm.Optional(int, size=64, default=0) alive = orm.Optional(bool, default=True) torrents = orm.Set('TorrentState', reverse='trackers') diff --git a/Tribler/Core/Modules/tracker_manager.py b/Tribler/Core/Modules/tracker_manager.py index 04a42f2382b..307a5a145eb 100644 --- a/Tribler/Core/Modules/tracker_manager.py +++ b/Tribler/Core/Modules/tracker_manager.py @@ -85,13 +85,16 @@ def update_tracker_info(self, tracker_url, is_successful): :param tracker_url: The given tracker_url. :param is_successful: If the check was successful. """ - sanitized_tracker_url = get_uniformed_tracker_url(tracker_url) if tracker_url != u"DHT" else tracker_url - tracker = list(self.tracker_store.select(lambda g: g.url == sanitized_tracker_url)) + + if tracker_url == u"DHT": + return + + sanitized_tracker_url = get_uniformed_tracker_url(tracker_url) + tracker = self.tracker_store.get(lambda g: g.url == sanitized_tracker_url) if not tracker: self._logger.error("Trying to update the tracker info of an unknown tracker URL") return - tracker = tracker[0] current_time = int(time.time()) failures = 0 if is_successful else tracker.failures + 1 diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index 09a7438b144..b74d657db40 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -174,7 +174,7 @@ def update_tracker_info(self, tracker_url, value): @db_session def get_valid_trackers_of_torrent(self, torrent_id): """ Get a set of valid trackers for torrent. Also remove any invalid torrent.""" - db_tracker_list = self.tribler_session.lm.mds.TorrentState[database_blob(torrent_id)].trackers + db_tracker_list = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(torrent_id)).trackers return set([str(tracker.url) for tracker in db_tracker_list if is_valid_url(str(tracker.url)) or str(tracker.url) == u'DHT']) @@ -227,27 +227,22 @@ def add_gui_request(self, infohash, timeout=20, scrape_now=False): # get torrent's tracker list from DB tracker_set = self.get_valid_trackers_of_torrent(torrent_id) - if not tracker_set: - self._logger.warn(u"no trackers, skip GUI request. infohash: %s", hexlify(infohash)) - # TODO: add code to handle torrents with no tracker - return fail(Failure(RuntimeError("No trackers available for this torrent"))) - - deferred_list = [] - for tracker_url in tracker_set: - if tracker_url == u'DHT': - # Create a (fake) DHT session for the lookup - session = FakeDHTSession(self.tribler_session, infohash, timeout) - self._session_list['DHT'].append(session) - deferred_list.append(session.connect_to_tracker(). - addCallbacks(*self.get_callbacks_for_session(session))) - elif tracker_url != u'no-DHT': - session = self._create_session_for_request(tracker_url, timeout=timeout) - session.add_infohash(infohash) - deferred_list.append(session.connect_to_tracker(). - addCallbacks(*self.get_callbacks_for_session(session))) - - return DeferredList(deferred_list, consumeErrors=True).addCallback( - lambda res: self.on_gui_request_completed(infohash, res)) + + deferred_list = [] + for tracker_url in tracker_set: + session = self._create_session_for_request(tracker_url, timeout=timeout) + session.add_infohash(infohash) + deferred_list.append(session.connect_to_tracker(). + addCallbacks(*self.get_callbacks_for_session(session))) + + # Create a (fake) DHT session for the lookup + session = FakeDHTSession(self.tribler_session, infohash, timeout) + self._session_list['DHT'].append(session) + deferred_list.append(session.connect_to_tracker(). + addCallbacks(*self.get_callbacks_for_session(session))) + + return DeferredList(deferred_list, consumeErrors=True).addCallback( + lambda res: self.on_gui_request_completed(infohash, res)) def on_session_error(self, session, failure): """ @@ -325,6 +320,6 @@ def publish_torrent_result(self, response): return content = (response['infohash'], response['seeders'], response['leechers'], response['last_check']) if self.tribler_session.lm.popularity_community: - self.tribler_session.lm.popularity_community.queue_content(TYPE_TORRENT_HEALTH, content) + self.tribler_session.lm.popularity_community.queue_content(content) else: self._logger.info("Popular community not available to publish torrent checker result") diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py index a6857653be7..632952eb155 100644 --- a/Tribler/Core/Upgrade/db71_to_pony.py +++ b/Tribler/Core/Upgrade/db71_to_pony.py @@ -26,7 +26,7 @@ def dispesy_cid_to_pk(self, dispersy_cid): return database_blob(unhexlify(("%X" % dispersy_cid).zfill(128))) def pseudo_signature(self): - return database_blob('\x00' * 32) + return database_blob(os.urandom(32)) def final_timestamp(self): return 1 << 62 @@ -158,20 +158,19 @@ def get_old_torrents(self, batch_size=BATCH_SIZE, offset=0): for (t, h) in old_torrents: try: m = mds.TorrentMetadata(**t) - except MalformedTrackerURLException: - print t - exit(1) - + except: + continue - if h["last_check"] > 0: - m.health.set(**h) x += batch_size print ("%i/%i" % (x, total_to_convert)) with db_session: old_channels = d.get_old_channels() for c in old_channels: - mds.ChannelMetadata(**c) + try: + mds.ChannelMetadata(**c) + except: + continue with db_session: for c in mds.ChannelMetadata.select()[:]: diff --git a/Tribler/Test/Core/Utilities/test_tracker_utils.py b/Tribler/Test/Core/Utilities/test_tracker_utils.py index 99284da086a..32569359ce1 100644 --- a/Tribler/Test/Core/Utilities/test_tracker_utils.py +++ b/Tribler/Test/Core/Utilities/test_tracker_utils.py @@ -62,6 +62,10 @@ def test_uniform_bad_urlenc(self): result = get_uniformed_tracker_url(u'http://btjunkie.org/?do=upload') self.assertIsNone(result) + def test_uniform_empty(self): + result = get_uniformed_tracker_url(u'') + self.assertIsNone(result) + class TestParseTrackerUrl(TriblerCoreTest): diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 42be3f9a3d9..1a726a920a5 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -1090,7 +1090,7 @@ background-color: #e67300; - 4 + 1 @@ -2363,38 +2363,6 @@ font-size: 14px; 0 - - - - - 0 - 0 - - - - - 0 - 30 - - - - - 16777215 - 30 - - - - font-size: 14px; background-color: #cc6600; -color: #eee; - - - Your channel has uncommitted changes. - - - Qt::AlignCenter - - - @@ -2468,46 +2436,90 @@ color: #eee; - + - + 0 0 - 180 - 28 - - - - - 180 - 28 - - - - - 200 + 50 0 - - QLineEdit { -border-radius: 3px; -} -QLineEdit:focus, QLineEdit::hover { -background-color: #404040; -color: white; -} - - - - - - Filter - + + + + + + 0 + 0 + + + + + 0 + 30 + + + + + 16777215 + 30 + + + + font-size: 14px; background-color: #cc6600; +color: #eee; + + + Your channel has uncommitted changes. + + + Qt::AlignCenter + + + + + + + Qt::Horizontal + + + + 10 + 20 + + + + + + + + + 0 + 28 + + + + + 16777215 + 28 + + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + APPLY CHANGES + + + + @@ -2527,26 +2539,45 @@ color: white; - + + + + 0 + 0 + + - 0 + 180 28 - 16777215 + 180 28 + + + 200 + 0 + + - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; + QLineEdit { +border-radius: 3px; +} +QLineEdit:focus, QLineEdit::hover { +background-color: #404040; +color: white; +} - APPLY CHANGES + + + + Filter diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index b117b864ed7..193c228b207 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -67,8 +67,7 @@ def initialize_edit_channel_page(self): self.window().edit_channel_torrents_container.details_container.hide() def update_channel_commit_views(self): - self.window().dirty_channel_status_bar.setHidden(not self.channel_dirty) - self.window().edit_channel_commit_button.setEnabled(self.channel_dirty) + self.window().commit_control_bar.setHidden(not self.channel_dirty) def load_my_channel_overview(self): if not self.channel_overview: diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 092d1768dd4..edacbd21cc5 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -1,5 +1,7 @@ from __future__ import absolute_import, division +from abc import abstractmethod + from PyQt5.QtCore import QModelIndex, QPoint, pyqtSignal from PyQt5.QtWidgets import QTableView @@ -27,9 +29,19 @@ class TriblerContentTableView(LazyTableView): def __init__(self, parent=None): LazyTableView.__init__(self, parent) - self.delegate = None self.setMouseTracking(True) + self.delegate = self.init_delegate() + + self.setItemDelegate(self.delegate) + self.mouse_moved.connect(self.delegate.on_mouse_moved) + self.delegate.redraw_required.connect(self.redraw) + + @abstractmethod + def init_delegate(self): + # This method should create a QT Delegate object and return it + pass + def mouseMoveEvent(self, event): index = QModelIndex(self.indexAt(event.pos())) self.mouse_moved.emit(event.pos(), index) @@ -63,6 +75,7 @@ def on_play_request_done(_): class SubscribeButtonMixin(TriblerContentTableView): + on_subscribed_channel = pyqtSignal(QModelIndex) def on_subscribe_control_clicked(self, index): if index.model().data_items[index.row()][u'status'] == 6: # LEGACY ENTRIES! return @@ -79,6 +92,8 @@ def on_subscribe_control_clicked(self, index): class ItemClickedMixin(TriblerContentTableView): + on_channel_clicked = pyqtSignal(dict) + on_torrent_clicked = pyqtSignal(QModelIndex, dict) def on_table_item_clicked(self, item): column_position = self.model().column_position if (ACTION_BUTTONS in column_position and item.column() == column_position[ACTION_BUTTONS]) or \ @@ -127,16 +142,9 @@ class SearchResultsTableView(ItemClickedMixin, DownloadButtonMixin, PlayButtonMi """ This table displays search results, which can be both torrents and channels. """ - on_torrent_clicked = pyqtSignal(QModelIndex, dict) - on_channel_clicked = pyqtSignal(dict) def __init__(self, parent=None): TriblerContentTableView.__init__(self, parent) - self.delegate = SearchResultsDelegate() - - self.setItemDelegate(self.delegate) - self.mouse_moved.connect(self.delegate.on_mouse_moved) - self.delegate.redraw_required.connect(self.redraw) # Mix-in connects self.clicked.connect(self.on_table_item_clicked) @@ -144,6 +152,9 @@ def __init__(self, parent=None): self.delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) self.delegate.download_button.clicked.connect(self.on_download_button_clicked) + def init_delegate(self): + return SearchResultsDelegate() + def resizeEvent(self, _): self.setColumnWidth(0, 100) self.setColumnWidth(2, 100) @@ -156,15 +167,9 @@ class TorrentsTableView(ItemClickedMixin, CommitControlMixin, DownloadButtonMixi """ This table displays various torrents. """ - on_torrent_clicked = pyqtSignal(QModelIndex, dict) def __init__(self, parent=None): TriblerContentTableView.__init__(self, parent) - self.delegate = TorrentsButtonsDelegate() - - self.setItemDelegate(self.delegate) - self.mouse_moved.connect(self.delegate.on_mouse_moved) - self.delegate.redraw_required.connect(self.redraw) # Mix-in connects self.clicked.connect(self.on_table_item_clicked) @@ -172,6 +177,9 @@ def __init__(self, parent=None): self.delegate.commit_control.clicked.connect(self.on_commit_control_clicked) self.delegate.download_button.clicked.connect(self.on_download_button_clicked) + def init_delegate(self): + return TorrentsButtonsDelegate() + def resizeEvent(self, _): if isinstance(self.model(), MyTorrentsContentModel): self.setColumnWidth(0, 100) @@ -191,21 +199,17 @@ class ChannelsTableView(ItemClickedMixin, SubscribeButtonMixin, """ This table displays various channels. """ - on_channel_clicked = pyqtSignal(dict) - on_unsubscribed_channel = pyqtSignal(QModelIndex) - on_subscribed_channel = pyqtSignal(QModelIndex) def __init__(self, parent=None): TriblerContentTableView.__init__(self, parent) - self.delegate = ChannelsButtonsDelegate() - self.setItemDelegate(self.delegate) - self.mouse_moved.connect(self.delegate.on_mouse_moved) - self.delegate.redraw_required.connect(self.redraw) # Mix-in connects self.clicked.connect(self.on_table_item_clicked) self.delegate.subscribe_control.clicked.connect(self.on_subscribe_control_clicked) + def init_delegate(self): + return ChannelsButtonsDelegate() + def resizeEvent(self, _): self.setColumnWidth(1, 150) self.setColumnWidth(2, 100) From d5b5949c51f860d0b8a8c7c884105198e4d3ea12 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Fri, 25 Jan 2019 15:08:01 +0100 Subject: [PATCH 14/38] timer-base channel commits in the GUI --- .../Core/APIImplementation/LaunchManyCore.py | 5 +- .../OrmBindings/channel_metadata.py | 34 +- .../MetadataStore/OrmBindings/channel_node.py | 2 + .../OrmBindings/torrent_metadata.py | 37 ++- .../Modules/MetadataStore/serialization.py | 48 +-- .../Modules/restapi/mychannel_endpoint.py | 9 +- .../Core/TorrentChecker/torrent_checker.py | 2 +- .../MetadataStore/test_channel_metadata.py | 39 ++- .../MetadataStore/test_torrent_metadata.py | 10 + .../Modules/RestApi/test_metadata_endpoint.py | 28 +- .../TorrentChecker/test_torrentchecker.py | 16 +- TriblerGUI/defs.py | 19 +- TriblerGUI/images/trash.svg | 61 ++++ TriblerGUI/qt_resources/mainwindow.ui | 314 ++++++++++-------- TriblerGUI/tribler_window.py | 2 +- TriblerGUI/widgets/createtorrentpage.py | 2 +- TriblerGUI/widgets/editchannelpage.py | 65 +++- TriblerGUI/widgets/lazytableview.py | 19 +- TriblerGUI/widgets/settingspage.py | 17 +- TriblerGUI/widgets/tablecontentdelegate.py | 7 +- TriblerGUI/widgets/tablecontentmodel.py | 9 +- TriblerGUI/widgets/tableiconbuttons.py | 3 + TriblerGUI/widgets/triblertablecontrollers.py | 5 +- 23 files changed, 513 insertions(+), 240 deletions(-) create mode 100644 TriblerGUI/images/trash.svg diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index d150de8a2d2..6b41b0343ec 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -229,7 +229,7 @@ def load_ipv8_overlays(self): random_slots = self.session.config.get_tunnel_community_random_slots() competing_slots = self.session.config.get_tunnel_community_competing_slots() - dht_provider = DHTCommunityProvider(self.dht_community, self.session.config.get_dispersy_port()) + dht_provider = DHTCommunityProvider(self.dht_community, self.session.config.get_ipv8_port()) self.tunnel_community = community_cls(peer, self.ipv8.endpoint, self.ipv8.network, tribler_session=self.session, dht_provider=dht_provider, @@ -423,9 +423,6 @@ def remove(self, d, removecontent=False, removestate=True, hidden=False): if infohash in self.downloads: del self.downloads[infohash] - if not hidden: - self.remove_id(infohash) - return out or succeed(None) def get_downloads(self): diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index b3939878e41..22d407e3744 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -194,6 +194,9 @@ def commit_channel_torrent(self): """ new_infohash = None md_list = self.staged_entries_list + if not md_list: + return None + try: update_dict = self.update_channel_torrent(md_list) except IOError: @@ -233,9 +236,6 @@ def add_torrent_to_channel(self, tdef, extra_info): :param tdef: The torrent definition file of the torrent to add :param extra_info: Optional extra info to add to the torrent """ - if self.get_torrent(tdef.get_infohash()): - raise DuplicateTorrentFileError() - if extra_info: tags = extra_info.get('description', '') elif self._category_filter: @@ -243,16 +243,36 @@ def add_torrent_to_channel(self, tdef, extra_info): else: tags = '' - torrent_metadata = db.TorrentMetadata.from_dict({ + new_entry_dict = { "infohash": tdef.get_infohash(), "title": tdef.get_name_as_unicode(), "tags": tags, "size": tdef.get_length(), "torrent_date": datetime.fromtimestamp(tdef.get_creation_date()), "tracker_info": get_uniformed_tracker_url(tdef.get_tracker() or '') or '', - "status": NEW - }) - torrent_metadata.parents.add(self) + "status": NEW} + + # See if the torrent is already in the channel + old_torrent = self.get_torrent(tdef.get_infohash()) + if old_torrent: + # If it is there, check if we were going to delete it + if old_torrent.status == TODELETE: + if old_torrent.metadata_conflicting(new_entry_dict): + # Metadata from torrent we're trying to add is conflicting with the + # deleted old torrent's metadata. We will replace the old metadata. + new_timestamp = self._clock.tick() + old_torrent.set(timestamp=new_timestamp, **new_entry_dict) + old_torrent.sign() + else: + # No conflict. This means the user is trying to replace the deleted torrent + # with the same one. Just recover the old one. + old_torrent.status = COMMITTED + torrent_metadata = old_torrent + else: + raise DuplicateTorrentFileError() + else: + torrent_metadata = db.TorrentMetadata.from_dict(new_entry_dict) + torrent_metadata.parents.add(self) return torrent_metadata @property diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index a21fcdbaed1..eb3c05ccb08 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -54,6 +54,8 @@ class ChannelNode(db.Entity): id_ = orm.Required(int, size=64) orm.composite_index(public_key, id_) # Requires Pony 0.7.7+ with Python2 + timestamp = orm.Required(int, size=64, default=0) + signature = orm.Required(database_blob, unique=True) # Local diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 4d21734a02c..1bf586d49b5 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -6,7 +6,7 @@ from pony import orm from pony.orm import db_session, raw_sql -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY, TODELETE from Tribler.Core.Modules.MetadataStore.serialization import TorrentMetadataPayload, REGULAR_TORRENT from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url from Tribler.pyipv8.ipv8.database import database_blob @@ -17,7 +17,6 @@ class TorrentMetadata(db.ChannelNode): _discriminator_ = REGULAR_TORRENT # Serializable - timestamp = orm.Required(int, size=64, default=0) infohash = orm.Optional(database_blob, default='\x00' * 20) size = orm.Optional(int, size=64, default=0) torrent_date = orm.Optional(datetime, default=datetime.utcnow) @@ -35,14 +34,22 @@ def __init__(self, *args, **kwargs): if "health" not in kwargs and "infohash" in kwargs: kwargs["health"] = db.TorrentState.get(infohash=kwargs["infohash"]) or db.TorrentState( infohash=kwargs["infohash"]) - if 'tracker_info' in kwargs: - sanitized_url = get_uniformed_tracker_url(kwargs["tracker_info"]) - if sanitized_url: - tracker = db.TrackerState.get(url=sanitized_url) or db.TrackerState(url=sanitized_url) - kwargs["health"].trackers.add(tracker) super(TorrentMetadata, self).__init__(*args, **kwargs) + if 'tracker_info' in kwargs: + self.add_tracker(kwargs["tracker_info"]) + + def add_tracker(self, tracker_url): + sanitized_url = get_uniformed_tracker_url(tracker_url) + if sanitized_url: + tracker = db.TrackerState.get(url=sanitized_url) or db.TrackerState(url=sanitized_url) + self.health.trackers.add(tracker) + + def before_update(self): + self.add_tracker(self.tracker_info) + + def get_magnet(self): return ("magnet:?xt=urn:btih:%s&dn=%s" % (str(self.infohash).encode('hex'), self.title)) + \ @@ -95,7 +102,8 @@ def get_random_torrents(cls, limit): @classmethod @db_session - def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, channel_pk=False): + def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, channel_pk=False, + exclude_deleted=False): """ Get some torrents. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. @@ -107,6 +115,8 @@ def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte # We only want torrents, not channel torrents pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) + if exclude_deleted: + pony_query = pony_query.where(lambda g: g.status != TODELETE) # Filter on channel if channel_pk: @@ -138,4 +148,15 @@ def to_simple_dict(self, include_trackers=False): return simple_dict + def metadata_conflicting(self, b): + # Check if metadata in the given dict has conflicts with this entry + # WARNING! This does NOT check the INFOHASH + a = self.to_dict() + for comp in ["title", "size", "tags", "torrent_date", "tracker_info"]: + if (comp not in b) or (str(a[comp]) == str(b[comp])): + continue + return True + return False + + return TorrentMetadata diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index e632f50ded0..3713042398c 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -155,13 +155,14 @@ def from_file(cls, filepath): class ChannelNodePayload(SignedPayload): - format_list = SignedPayload.format_list + ['Q', 'Q'] + format_list = SignedPayload.format_list + ['Q', 'Q', 'Q'] def __init__(self, metadata_type, public_key, - id_, origin_id, + id_, origin_id, timestamp, **kwargs): self.id_ = id_ self.origin_id = origin_id + self.timestamp = timestamp super(ChannelNodePayload, self).__init__(metadata_type, public_key, **kwargs) @@ -169,21 +170,23 @@ def to_pack_list(self): data = super(ChannelNodePayload, self).to_pack_list() data.append(('Q', self.id_)) data.append(('Q', self.origin_id)) + data.append(('Q', self.timestamp)) return data @classmethod def from_unpack_list(cls, metadata_type, public_key, - id_, origin_id, + id_, origin_id, timestamp, **kwargs): return ChannelNodePayload(metadata_type, public_key, - id_, origin_id, + id_, origin_id, timestamp, **kwargs) def to_dict(self): dct = super(ChannelNodePayload, self).to_dict() dct.update({ "id_": self.id_, - "origin_id": self.origin_id + "origin_id": self.origin_id, + "timestamp": self.timestamp }) return dct @@ -192,13 +195,12 @@ class TorrentMetadataPayload(ChannelNodePayload): """ Payload for metadata that stores a torrent. """ - format_list = ChannelNodePayload.format_list + ['Q', '20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] + format_list = ChannelNodePayload.format_list + ['20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] def __init__(self, metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, **kwargs): - self.timestamp = timestamp self.infohash = str(infohash) self.size = size self.torrent_date = time2int(torrent_date) if isinstance(torrent_date, datetime) else torrent_date @@ -206,12 +208,11 @@ def __init__(self, metadata_type, public_key, self.tags = tags.decode('utf-8') if type(tags) == str else tags self.tracker_info = tracker_info.decode('utf-8') if type(tracker_info) == str else tracker_info super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, - id_, origin_id, + id_, origin_id, timestamp, **kwargs) def to_pack_list(self): data = super(TorrentMetadataPayload, self).to_pack_list() - data.append(('Q', self.timestamp)) data.append(('20s', self.infohash)) data.append(('Q', self.size)) data.append(('I', self.torrent_date)) @@ -222,16 +223,15 @@ def to_pack_list(self): @classmethod def from_unpack_list(cls, metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, **kwargs): return TorrentMetadataPayload(metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, **kwargs) def to_dict(self): dct = super(TorrentMetadataPayload, self).to_dict() dct.update({ - "timestamp": self.timestamp, "infohash": self.infohash, "size": self.size, "torrent_date": int2time(self.torrent_date), @@ -255,14 +255,14 @@ class ChannelMetadataPayload(TorrentMetadataPayload): format_list = TorrentMetadataPayload.format_list + ['Q'] def __init__(self, metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, num_entries, **kwargs): self.num_entries = num_entries super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, **kwargs) def to_pack_list(self): @@ -272,13 +272,13 @@ def to_pack_list(self): @classmethod def from_unpack_list(cls, metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, num_entries, **kwargs): return ChannelMetadataPayload(metadata_type, public_key, - id_, origin_id, - timestamp, infohash, size, torrent_date, title, tags, tracker_info, + id_, origin_id, timestamp, + infohash, size, torrent_date, title, tags, tracker_info, num_entries, **kwargs) diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index e163fff698d..710793e3388 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -112,9 +112,10 @@ def render_GET(self, request): request.args['channel'] = [str(my_channel.public_key).encode('hex')] first, last, sort_by, sort_asc, query_filter, channel = \ SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) + exclude_deleted = 'exclude_deleted' in request.args and request.args['exclude_deleted'] torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, channel) + first, last, sort_by, sort_asc, query_filter, channel, exclude_deleted=exclude_deleted) torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ @@ -326,8 +327,8 @@ def render_POST(self, request): request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": "your channel has not been created"}) - my_channel.commit_channel_torrent() - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) + if my_channel.commit_channel_torrent(): + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) return json.dumps({"success": True}) diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index b74d657db40..b785c13797b 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -130,7 +130,7 @@ def _task_select_tracker(self): infohashes.append(torrent.infohash) if len(infohashes) == 0: - # We have not torrent to recheck for this tracker. Still update the last_check for this tracker. + # We have no torrent to recheck for this tracker. Still update the last_check for this tracker. self._logger.info("No torrent to check for tracker %s", tracker_url) self.update_tracker_info(tracker_url, True) return succeed(None) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index e5527e450df..d4e354ee086 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -1,18 +1,16 @@ from __future__ import absolute_import import os -from binascii import hexlify, unhexlify +from binascii import unhexlify from datetime import datetime from pony.orm import db_session - from six.moves import xrange - from twisted.internet.defer import inlineCallbacks from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import CHANNEL_DIR_NAME_LENGTH, ROOT_CHANNEL_ID, \ entries_to_chunk -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW, TODELETE, COMMITTED from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef @@ -162,7 +160,7 @@ def test_get_channel_with_dirname(self): self.assertEqual(channel_metadata, channel_result) # Test for corner-case of channel PK starting with zeroes - channel_metadata.public_key = database_blob(unhexlify('0'*128)) + channel_metadata.public_key = database_blob(unhexlify('0' * 128)) channel_result = self.mds.ChannelMetadata.get_channel_with_dirname(channel_metadata.dir_name) self.assertEqual(channel_metadata, channel_result) @@ -181,7 +179,7 @@ def test_add_metadata_to_channel(self): Test whether adding new torrents to a channel works as expected """ channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') - self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) + self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) channel_metadata.commit_channel_torrent() self.assertEqual(channel_metadata.id_, ROOT_CHANNEL_ID) @@ -199,6 +197,35 @@ def test_add_torrent_to_channel(self): self.assertTrue(channel_metadata.contents_list) self.assertRaises(DuplicateTorrentFileError, channel_metadata.add_torrent_to_channel, tdef, None) + @db_session + def test_restore_torrent_in_channel(self): + """ + Test if the torrent scheduled for deletion is restored/updated after the user + tries to re-add it. + """ + channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') + tdef = TorrentDef.load(TORRENT_UBUNTU_FILE) + md = channel_metadata.add_torrent_to_channel(tdef, None) + + # Check correct re-add + md.status = TODELETE + md_updated = channel_metadata.add_torrent_to_channel(tdef, None) + self.assertEqual(md.status, COMMITTED) + self.assertEqual(md_updated, md) + self.assertTrue(md.has_valid_signature) + + # Check update of torrent properties from a new tdef + md.status = TODELETE + new_tracker_address = u'http://tribler.org/announce' + tdef.input['announce'] = new_tracker_address + md_updated = channel_metadata.add_torrent_to_channel(tdef, None) + self.assertEqual(md_updated, md) + self.assertEqual(md.status, NEW) + self.assertEqual(md.tracker_info, new_tracker_address) + self.assertTrue(md.has_valid_signature) + # In addition, check that the trackers table was properly updated + self.assertEqual(len(md.health.trackers), 2) + @db_session def test_delete_torrent_from_channel(self): """ diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index b9a2dd71f13..f5ef45be2d7 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -183,3 +183,13 @@ def test_get_torrents(self): torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=10, sort_by='title', channel_pk=channel_pk) self.assertEqual(len(torrents[0]), 5) self.assertEqual(torrents[1], 5) + + @db_session + def test_metadata_conflicting(self): + tdict = dict(self.torrent_template, title="lakes sheep", tags="video", infohash='\x00\xff') + md = self.mds.TorrentMetadata.from_dict(tdict) + self.assertFalse(md.metadata_conflicting(tdict)) + self.assertTrue(md.metadata_conflicting(dict(tdict, title="bla"))) + tdict.pop('title') + self.assertFalse(md.metadata_conflicting(tdict)) + diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index 56460165f51..0737492023f 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -4,15 +4,15 @@ from binascii import hexlify import six -from six.moves import xrange - from pony.orm import db_session +from six.moves import xrange from twisted.internet.defer import inlineCallbacks from Tribler.Core.TorrentChecker.torrent_checker import TorrentChecker from Tribler.Core.Utilities.network_utils import get_random_port from Tribler.Core.Utilities.random_utils import random_infohash from Tribler.Test.Core.Modules.RestApi.base_api_test import AbstractApiTest +from Tribler.Test.Core.base_test import MockObject from Tribler.Test.tools import trial_timeout from Tribler.Test.util.Tracker.HTTPTracker import HTTPTracker from Tribler.Test.util.Tracker.UDPTracker import UDPTracker @@ -26,12 +26,14 @@ def setUp(self): yield super(BaseTestMetadataEndpoint, self).setUp() self.infohashes = [] + torrents_per_channel = 5 # Add a few channels with db_session: for ind in xrange(10): self.session.lm.mds.ChannelNode._my_key = default_eccrypto.generate_key('curve25519') - _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) - for torrent_ind in xrange(5): + _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0), + num_entries=torrents_per_channel) + for torrent_ind in xrange(torrents_per_channel): rand_infohash = random_infohash() self.infohashes.append(rand_infohash) _ = self.session.lm.mds.TorrentMetadata(title='torrent%d' % torrent_ind, infohash=rand_infohash) @@ -47,6 +49,7 @@ def test_get_channels(self): """ Test whether we can query some channels in the database with the REST API """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['channels']), 10) @@ -58,6 +61,7 @@ def test_get_channels_invalid_sort(self): """ Test whether we can query some channels in the database with the REST API and an invalid sort parameter """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['channels']), 10) @@ -69,6 +73,7 @@ def test_get_subscribed_channels(self): """ Test whether we can successfully query channels we are subscribed to with the REST API """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['channels']), 5) @@ -112,6 +117,7 @@ def test_get_torrents(self): """ Test whether we can query some torrents in the database with the REST API """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['torrents']), 5) @@ -134,6 +140,7 @@ def test_get_popular_channels(self): """ Test whether we can retrieve popular channels with the REST API """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['channels']), 5) @@ -172,6 +179,7 @@ def test_get_random_torrents(self): """ Test whether we can retrieve some random torrents with the REST API """ + def on_response(response): json_dict = json.loads(response) self.assertEqual(len(json_dict['torrents']), 5) @@ -240,11 +248,23 @@ def verify_response_no_trackers(response): u"leechers": 11, u"seeders": 12, u"infohash": six.text_type(hexlify(infohash)) + }, + u"DHT": { + u"leechers": 2, + u"seeders": 1, + u"infohash": six.text_type(hexlify(infohash)) } } } self.assertDictEqual(json_response, expected_dict) + # Add mock DHT response + def get_metainfo(infohash, callback, **_): + callback({"seeders": 1, "leechers": 2}) + + self.session.lm.ltmgr = MockObject() + self.session.lm.ltmgr.get_metainfo = get_metainfo + # Left for compatibility with other tests in this object self.udp_tracker.start() self.http_tracker.start() diff --git a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py index bca50c389ac..c63642076e1 100644 --- a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py +++ b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py @@ -13,7 +13,6 @@ from Tribler.Test.Core.base_test import MockObject from Tribler.Test.test_as_server import TestAsServer from Tribler.Test.tools import trial_timeout -from Tribler.community.popularity.repository import TYPE_TORRENT_HEALTH class TestTorrentChecker(TestAsServer): @@ -36,6 +35,13 @@ def setUp(self): self.torrent_checker = self.session.lm.torrent_checker self.torrent_checker.listen_on_udp = lambda: None + def get_metainfo(infohash, callback, **_): + callback({"seeders": 1, "leechers": 2}) + + self.session.lm.ltmgr = MockObject() + self.session.lm.ltmgr.get_metainfo = get_metainfo + self.session.lm.ltmgr.shutdown = lambda : None + def test_initialize(self): """ Test the initialization of the torrent checker @@ -170,12 +176,11 @@ def _fake_logger_info(torrent_checker, msg): original_logger_info = self.torrent_checker._logger.info self.torrent_checker._logger.info = lambda msg: _fake_logger_info(self.torrent_checker, msg) - def popularity_community_queue_content(torrent_checker, _type, _): + def popularity_community_queue_content(torrent_checker, _): torrent_checker.popularity_community_queue_content_called = True - torrent_checker.popularity_community_queue_content_called_type = _type - self.torrent_checker.tribler_session.lm.popularity_community.queue_content = lambda _type, _content: \ - popularity_community_queue_content(self.torrent_checker, _type, _content) + self.torrent_checker.tribler_session.lm.popularity_community.queue_content = lambda _content: \ + popularity_community_queue_content(self.torrent_checker, _content) # Case1: Fake torrent checker response, seeders:0 fake_response = {'infohash': 'a'*20, 'seeders': 0, 'leechers': 0, 'last_check': time.time()} @@ -189,7 +194,6 @@ def popularity_community_queue_content(torrent_checker, _type, _): self.torrent_checker.publish_torrent_result(fake_response) self.assertTrue(self.torrent_checker.popularity_community_queue_content_called) - self.assertEqual(self.torrent_checker.popularity_community_queue_content_called_type, TYPE_TORRENT_HEALTH) # Case3: Popular community is None self.torrent_checker.tribler_session.lm.popularity_community = None diff --git a/TriblerGUI/defs.py b/TriblerGUI/defs.py index e58793d884d..514d76f071e 100644 --- a/TriblerGUI/defs.py +++ b/TriblerGUI/defs.py @@ -14,16 +14,15 @@ PAGE_VIDEO_PLAYER = 5 PAGE_SUBSCRIBED_CHANNELS = 6 PAGE_DOWNLOADS = 7 -PAGE_PLAYLIST_DETAILS = 8 -PAGE_LOADING = 9 -PAGE_DISCOVERING = 10 -PAGE_DISCOVERED = 11 -PAGE_TRUST = 12 -PAGE_MARKET = 13 -PAGE_MARKET_TRANSACTIONS = 14 -PAGE_MARKET_WALLETS = 15 -PAGE_MARKET_ORDERS = 16 -PAGE_TOKEN_MINING_PAGE = 17 +PAGE_LOADING = 8 +PAGE_DISCOVERING = 9 +PAGE_DISCOVERED = 10 +PAGE_TRUST = 11 +PAGE_MARKET = 12 +PAGE_MARKET_TRANSACTIONS = 13 +PAGE_MARKET_WALLETS = 14 +PAGE_MARKET_ORDERS = 15 +PAGE_TOKEN_MINING_PAGE = 16 PAGE_CHANNEL_CONTENT = 0 PAGE_CHANNEL_COMMENTS = 1 diff --git a/TriblerGUI/images/trash.svg b/TriblerGUI/images/trash.svg new file mode 100644 index 00000000000..b612031895d --- /dev/null +++ b/TriblerGUI/images/trash.svg @@ -0,0 +1,61 @@ + + + + + + image/svg+xml + + + + + + + + trash + + + + diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 1a726a920a5..6f45a35ccc7 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -6,7 +6,7 @@ 0 0 - 875 + 969 777 @@ -2409,6 +2409,49 @@ font-size: 14px; + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + ADD + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 0 + + + + @@ -2419,13 +2462,56 @@ font-size: 14px; + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE ALL + + + Qt::Horizontal - QSizePolicy::Expanding + QSizePolicy::Maximum @@ -2435,6 +2521,62 @@ font-size: 14px; + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 5 + 20 + + + + + + + + + 0 + 24 + + + + + 16777215 + 24 + + + + PointingHandCursor + + + border-radius: 12px; +padding-left: 4px; +padding-right: 4px; + + + REMOVE SELECTED + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + @@ -2603,148 +2745,6 @@ color: white; - - - - - 0 - 50 - - - - - 16777215 - 50 - - - - - 8 - - - 8 - - - 8 - - - 8 - - - - - Qt::Horizontal - - - - 390 - 20 - - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE SELECTED - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - REMOVE ALL - - - - - - - - 0 - 0 - - - - - 0 - 24 - - - - - 16777215 - 24 - - - - PointingHandCursor - - - border-radius: 12px; -padding-left: 4px; -padding-right: 4px; - - - ADD - - - - - - @@ -4513,6 +4513,32 @@ color: white; + + + + font-weight: bold; +color: white; + + + Personal channel settings + + + + + + + Commit changes automatically +(requires Tribler restart) + + + + + + + + + + diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index a01afbd112f..314d588bcb6 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -153,7 +153,7 @@ def on_state_update(new_state): self.search_results_page.initialize_search_results_page() self.settings_page.initialize_settings_page() self.subscribed_channels_page.initialize() - self.edit_channel_page.initialize_edit_channel_page() + self.edit_channel_page.initialize_edit_channel_page(self.gui_settings) self.downloads_page.initialize_downloads_page() self.home_page.initialize_home_page() self.loading_page.initialize_loading_page() diff --git a/TriblerGUI/widgets/createtorrentpage.py b/TriblerGUI/widgets/createtorrentpage.py index eeeb2e0e8ae..ebb7acbc0e5 100644 --- a/TriblerGUI/widgets/createtorrentpage.py +++ b/TriblerGUI/widgets/createtorrentpage.py @@ -120,7 +120,7 @@ def on_torrent_to_channel_added(self, result): self.window().edit_channel_create_torrent_progress_label.hide() if 'added' in result: self.window().edit_channel_details_stacked_widget.setCurrentIndex(PAGE_EDIT_CHANNEL_TORRENTS) - self.window().edit_channel_torrents_list.model.refresh() + self.window().edit_channel_page.load_my_torrents() def on_remove_entry(self): self.window().create_torrent_files_list.takeItem(self.selected_item_index) diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index 193c228b207..4bd364870af 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -4,7 +4,7 @@ import urllib from base64 import b64encode -from PyQt5.QtCore import QDir, pyqtSignal +from PyQt5.QtCore import QDir, pyqtSignal, QTimer from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QAction, QFileDialog, QWidget @@ -13,9 +13,12 @@ from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog from TriblerGUI.tribler_action_menu import TriblerActionMenu from TriblerGUI.tribler_request_manager import TriblerRequestManager +from TriblerGUI.utilities import get_gui_setting from TriblerGUI.widgets.tablecontentmodel import MyTorrentsContentModel from TriblerGUI.widgets.triblertablecontrollers import MyTorrentsTableViewController +CHANNEL_COMMIT_DELAY = 30000 # milliseconds + class EditChannelPage(QWidget): """ @@ -35,8 +38,15 @@ def __init__(self): self.model = None self.controller = None self.channel_dirty = False + self.gui_settings = None + self.commit_timer = None + self.autocommit_enabled = None + get_gui_setting(self.gui_settings, "autocommit_enabled", True, + is_bool=True) if self.gui_settings else True + + def initialize_edit_channel_page(self, gui_settings): + self.gui_settings = gui_settings - def initialize_edit_channel_page(self): self.window().create_channel_intro_button.clicked.connect(self.on_create_channel_intro_button_clicked) self.window().create_channel_form.hide() @@ -55,6 +65,9 @@ def initialize_edit_channel_page(self): self.window().export_channel_button.clicked.connect(self.on_export_mdblob) + # TODO: re-enable remove_selected button + self.window().remove_selected_button.setHidden(True) + # Connect torrent addition/removal buttons self.window().remove_selected_button.clicked.connect(self.on_torrents_remove_selected_clicked) self.window().remove_all_button.clicked.connect(self.on_torrents_remove_all_clicked) @@ -65,9 +78,34 @@ def initialize_edit_channel_page(self): self.window().edit_channel_torrents_num_items_label, self.window().edit_channel_torrents_filter) self.window().edit_channel_torrents_container.details_container.hide() - - def update_channel_commit_views(self): - self.window().commit_control_bar.setHidden(not self.channel_dirty) + self.autocommit_enabled = get_gui_setting(self.gui_settings, "autocommit_enabled", True, + is_bool=True) if self.gui_settings else True + + # Commit the channel just in case there are uncommitted changes left since the last time (e.g. Tribler crashed) + # The timer thing here is a workaround for race condition with the core startup + if self.autocommit_enabled: + if not self.commit_timer: + self.commit_timer = QTimer() + self.commit_timer.setSingleShot(True) + self.commit_timer.timeout.connect(self.autocommit_fired) + + self.controller.table_view.setColumnHidden(3, True) + self.model.exclude_deleted = True + self.commit_timer.stop() + self.commit_timer.start(10000) + else: + self.controller.table_view.setColumnHidden(4, True) + self.model.exclude_deleted = False + + def update_channel_commit_views(self, deleted_index=None): + if self.channel_dirty and self.autocommit_enabled: + self.commit_timer.stop() + self.commit_timer.start(CHANNEL_COMMIT_DELAY) + if deleted_index: + # TODO: instead of reloading the whole table, just remove the deleted row and update start and end + self.load_my_torrents() + + self.window().commit_control_bar.setHidden(not self.channel_dirty or self.autocommit_enabled) def load_my_channel_overview(self): if not self.channel_overview: @@ -338,6 +376,20 @@ def on_torrent_from_url_dialog_done(self, action): self.dialog.close_dialog() self.dialog = None + def autocommit_fired(self): + def commit_channel(overview): + try: + if overview['mychannel']['dirty']: + TriblerRequestManager().perform_request("mychannel/commit", lambda _: None, method='POST', + capture_errors=False) + except KeyError: + return + + if self.channel_overview: + self.clicked_edit_channel_commit_button() + else: + TriblerRequestManager().perform_request("mychannel", commit_channel, capture_errors=False) + # Commit button-related methods def clicked_edit_channel_commit_button(self): request_mgr = TriblerRequestManager() @@ -351,7 +403,8 @@ def on_channel_committed(self, result): self.channel_dirty = False self.update_channel_commit_views() self.on_commit.emit() - self.load_my_torrents() + if not self.autocommit_enabled: + self.load_my_torrents() def add_torrent_to_channel(self, filename): with open(filename, "rb") as torrent_file: diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index edacbd21cc5..59aa9c3f90b 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -76,6 +76,8 @@ def on_play_request_done(_): class SubscribeButtonMixin(TriblerContentTableView): on_subscribed_channel = pyqtSignal(QModelIndex) + on_unsubscribed_channel = pyqtSignal(QModelIndex) + def on_subscribe_control_clicked(self, index): if index.model().data_items[index.row()][u'status'] == 6: # LEGACY ENTRIES! return @@ -94,6 +96,7 @@ def on_subscribe_control_clicked(self, index): class ItemClickedMixin(TriblerContentTableView): on_channel_clicked = pyqtSignal(dict) on_torrent_clicked = pyqtSignal(QModelIndex, dict) + def on_table_item_clicked(self, item): column_position = self.model().column_position if (ACTION_BUTTONS in column_position and item.column() == column_position[ACTION_BUTTONS]) or \ @@ -134,7 +137,15 @@ def on_torrent_status_updated(self, json_result, index): index.model().data_items[index.row()][u'status'] = json_result['new_status'] self.window().edit_channel_page.channel_dirty = json_result['dirty'] - self.window().edit_channel_page.update_channel_commit_views() + self.window().edit_channel_page.update_channel_commit_views(deleted_index=index) + + +class DeleteButtonMixin(CommitControlMixin): + def on_delete_button_clicked(self, index): + request_mgr = TriblerRequestManager() + request_mgr.perform_request("mychannel/torrents/%s" % index.model().data_items[index.row()][u'infohash'], + lambda response: self.on_torrent_status_updated(response, index), + data='status=%d' % COMMIT_STATUS_TODELETE, method='PATCH') class SearchResultsTableView(ItemClickedMixin, DownloadButtonMixin, PlayButtonMixin, SubscribeButtonMixin, @@ -162,7 +173,7 @@ def resizeEvent(self, _): self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear -class TorrentsTableView(ItemClickedMixin, CommitControlMixin, DownloadButtonMixin, PlayButtonMixin, +class TorrentsTableView(ItemClickedMixin, DeleteButtonMixin, DownloadButtonMixin, PlayButtonMixin, TriblerContentTableView): """ This table displays various torrents. @@ -175,6 +186,7 @@ def __init__(self, parent=None): self.clicked.connect(self.on_table_item_clicked) self.delegate.play_button.clicked.connect(self.on_play_button_clicked) self.delegate.commit_control.clicked.connect(self.on_commit_control_clicked) + self.delegate.delete_button.clicked.connect(self.on_delete_button_clicked) self.delegate.download_button.clicked.connect(self.on_download_button_clicked) def init_delegate(self): @@ -185,7 +197,8 @@ def resizeEvent(self, _): self.setColumnWidth(0, 100) self.setColumnWidth(2, 100) self.setColumnWidth(3, 100) - self.setColumnWidth(1, self.width() - 304) # Few pixels offset so the horizontal scrollbar does not appear + self.setColumnWidth(4, 100) + self.setColumnWidth(1, self.width() - 404) # Few pixels offset so the horizontal scrollbar does not appear else: self.setColumnWidth(0, 100) self.setColumnWidth(2, 100) diff --git a/TriblerGUI/widgets/settingspage.py b/TriblerGUI/widgets/settingspage.py index d6c6bb70084..a5118c4af40 100644 --- a/TriblerGUI/widgets/settingspage.py +++ b/TriblerGUI/widgets/settingspage.py @@ -1,8 +1,7 @@ import sys - from PIL.ImageQt import ImageQt - from PyQt5 import QtGui, QtCore + from PyQt5.QtWidgets import QSizePolicy from PyQt5.QtWidgets import QWidget, QLabel, QFileDialog @@ -50,6 +49,7 @@ def initialize_settings_page(self): self.window().download_location_chooser_button.clicked.connect(self.on_choose_download_dir_clicked) self.window().watch_folder_chooser_button.clicked.connect(self.on_choose_watch_dir_clicked) + self.window().channel_autocommit_checkbox.stateChanged.connect(self.on_channel_autocommit_checkbox_changed) self.window().developer_mode_enabled_checkbox.stateChanged.connect(self.on_developer_mode_checkbox_changed) self.window().use_monochrome_icon_checkbox.stateChanged.connect(self.on_use_monochrome_icon_checkbox_changed) self.window().download_settings_anon_checkbox.stateChanged.connect(self.on_anon_download_state_changed) @@ -155,6 +155,9 @@ def on_emptying_tokens(self, data): else: ConfirmationDialog.show_error(self.window(), DEPENDENCY_ERROR_TITLE, DEPENDENCY_ERROR_MESSAGE) + def on_channel_autocommit_checkbox_changed(self, _): + self.window().gui_settings.setValue("autocommit_enabled", self.window().channel_autocommit_checkbox.isChecked()) + def on_developer_mode_checkbox_changed(self, _): self.window().gui_settings.setValue("debug", self.window().developer_mode_enabled_checkbox.isChecked()) self.window().left_menu_button_debug.setHidden(not self.window().developer_mode_enabled_checkbox.isChecked()) @@ -226,6 +229,9 @@ def initialize_with_settings(self, settings): self.window().watchfolder_enabled_checkbox.setChecked(settings['watch_folder']['enabled']) self.window().watchfolder_location_input.setText(settings['watch_folder']['directory']) + # Channel settings + self.window().channel_autocommit_checkbox.setChecked(get_gui_setting(gui_settings, "autocommit_enabled", True, is_bool=True)) + # Log directory self.window().log_location_input.setText(settings['general']['log_dir']) @@ -271,6 +277,7 @@ def initialize_with_settings(self, settings): self.window().developer_mode_enabled_checkbox.setChecked(get_gui_setting(gui_settings, "debug", False, is_bool=True)) self.window().checkbox_enable_resource_log.setChecked(settings['resource_monitor']['enabled']) + cpu_priority = 1 if 'cpu_priority' in settings['resource_monitor']: cpu_priority = int(settings['resource_monitor']['cpu_priority']) @@ -351,7 +358,7 @@ def save_settings(self): settings_data['libtorrent']['proxy_server'] = ":" if len(self.window().lt_proxy_username_input.text()) > 0 and \ - len(self.window().lt_proxy_password_input.text()) > 0: + len(self.window().lt_proxy_password_input.text()) > 0: settings_data['libtorrent']['proxy_auth'] = "%s:%s" % (self.window().lt_proxy_username_input.text(), self.window().lt_proxy_password_input.text()) else: @@ -386,7 +393,7 @@ def save_settings(self): except ValueError: ConfirmationDialog.show_error(self.window(), "Invalid value for bandwidth limit", "You've entered an invalid value for the maximum upload/download rate. " - "Please enter a whole number (max: %d)" % (sys.maxsize/1000)) + "Please enter a whole number (max: %d)" % (sys.maxsize / 1000)) return try: @@ -448,6 +455,8 @@ def on_settings_saved(self, data): if not data: return # Now save the GUI settings + self.window().gui_settings.setValue("autocommit_enabled", + self.window().channel_autocommit_checkbox.isChecked()) self.window().gui_settings.setValue("ask_download_settings", self.window().always_ask_location_checkbox.isChecked()) self.window().gui_settings.setValue("use_monochrome_icon", diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py index 73f10f19647..e58ca218f33 100644 --- a/TriblerGUI/widgets/tablecontentdelegate.py +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -9,7 +9,7 @@ from TriblerGUI.defs import ACTION_BUTTONS, COMMIT_STATUS_COMMITTED, COMMIT_STATUS_NEW, COMMIT_STATUS_TODELETE, \ HEALTH_CHECKING, HEALTH_DEAD, HEALTH_ERROR, HEALTH_GOOD, HEALTH_MOOT, HEALTH_UNCHECKED from TriblerGUI.utilities import get_image_path, get_health -from TriblerGUI.widgets.tableiconbuttons import DownloadIconButton, PlayIconButton +from TriblerGUI.widgets.tableiconbuttons import DownloadIconButton, PlayIconButton, DeleteIconButton class TriblerButtonsDelegate(QStyledItemDelegate): @@ -200,10 +200,11 @@ def __init__(self, parent=None): # On-demand buttons self.play_button = PlayIconButton() self.download_button = DownloadIconButton() - self.ondemand_container = [self.play_button, self.download_button] + self.delete_button = DeleteIconButton() + self.ondemand_container = [self.delete_button, self.play_button, self.download_button] self.commit_control = CommitStatusControl(u'status') - self.controls = [self.play_button, self.download_button, self.commit_control] + self.controls = [self.play_button, self.download_button, self.commit_control, self.delete_button] self.health_status_widget = HealthStatusDisplay() diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index a24188d64fa..81c4a13ae82 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -146,11 +146,16 @@ def __init__(self, channel_pk=''): class MyTorrentsContentModel(TorrentsContentModel): - columns = [u'category', u'name', u'size', u'status'] - column_headers = [u'Category', u'Name', u'Size', u''] + columns = [u'category', u'name', u'size', u'status', ACTION_BUTTONS] + column_headers = [u'Category', u'Name', u'Size', u'', u''] column_flags = { u'category': Qt.ItemIsEnabled | Qt.ItemIsSelectable, u'name': Qt.ItemIsEnabled | Qt.ItemIsSelectable, u'size': Qt.ItemIsEnabled | Qt.ItemIsSelectable, u'status': Qt.ItemIsEnabled | Qt.ItemIsSelectable, + ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable } + + def __init__(self, channel_pk=''): + TorrentsContentModel.__init__(self, channel_pk=channel_pk) + self.exclude_deleted = False diff --git a/TriblerGUI/widgets/tableiconbuttons.py b/TriblerGUI/widgets/tableiconbuttons.py index 41ed86e5d08..e7aca6f1426 100644 --- a/TriblerGUI/widgets/tableiconbuttons.py +++ b/TriblerGUI/widgets/tableiconbuttons.py @@ -64,3 +64,6 @@ class PlayIconButton(IconButton): def should_draw(self, index): return index.model().data_items[index.row()][u'category'] == u'Video' + +class DeleteIconButton(IconButton): + icon = QIcon(get_image_path("trash.svg")) diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index a88d56547db..125fdec170c 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -240,7 +240,7 @@ def on_torrents(self, response): class MyTorrentsTableViewController(TorrentsTableViewController): """ - This class manages the list with your the torrents in your own channel. + This class manages the list with the torrents in your own channel. """ def load_torrents(self, start=None, end=None): @@ -262,7 +262,8 @@ def load_torrents(self, start=None, end=None): "mychannel/torrents?first=%i&last=%i" % (start, end) + ('&sort_by=%s' % sort_by) + ('&sort_asc=%d' % sort_asc) - + ('&filter=%s' % filter_text), + + ('&filter=%s' % filter_text) + + ('&exclude_deleted=1' if self.model.exclude_deleted else ''), self.on_torrents) def on_torrents(self, response): From efc52e677128672e428c56e72892817e089ccd8b Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Fri, 25 Jan 2019 10:19:23 +0100 Subject: [PATCH 15/38] Enable twistd REST API by default --- twisted/plugins/tribler_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/twisted/plugins/tribler_plugin.py b/twisted/plugins/tribler_plugin.py index 812f72e2769..2bad4c135fe 100644 --- a/twisted/plugins/tribler_plugin.py +++ b/twisted/plugins/tribler_plugin.py @@ -45,7 +45,7 @@ class Options(usage.Options): optParameters = [ ["manhole", "m", 0, "Enable manhole telnet service listening at the specified port", int], ["statedir", "s", None, "Use an alternate statedir", str], - ["restapi", "p", -1, "Use an alternate port for the REST API", int], + ["restapi", "p", 8085, "Use an alternate port for the REST API", int], ["ipv8", "i", -1, "Use an alternate port for IPv8", int], ["libtorrent", "l", -1, "Use an alternate port for libtorrent", int], ["ipv8_bootstrap_override", "b", None, "Force the usage of specific IPv8 bootstrap server (ip:port)", From 1dbed583a80c213820f52fcf5f1180d304908c17 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Fri, 25 Jan 2019 14:13:20 +0100 Subject: [PATCH 16/38] Deferred loading of gigachannel manager The gigachannel manager should be loaded after libtorrent has initialized. Fixed issue when checking channel updates Fixed minor GUI inconsistency Fixed API port to 8085 again --- Tribler/Core/APIImplementation/LaunchManyCore.py | 6 ++++-- Tribler/Core/Modules/gigachannel_manager.py | 2 +- TriblerGUI/qt_resources/mainwindow.ui | 1 - run_tribler.py | 6 +----- 4 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 6b41b0343ec..1e26d47c77d 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -304,8 +304,6 @@ def init(self): channels_dir = os.path.join(self.session.config.get_chant_channels_dir()) database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'metadata.db') self.mds = MetadataStore(database_path, channels_dir, self.session.trustchain_keypair) - self.gigachannel_manager = GigaChannelManager(self.session) - self.gigachannel_manager.start() if self.session.config.get_dummy_wallets_enabled(): # For debugging purposes, we create dummy wallets @@ -331,6 +329,10 @@ def init(self): for port, protocol in self.upnp_ports: self.ltmgr.add_upnp_mapping(port, protocol) + if self.session.config.get_chant_enabled(): + self.gigachannel_manager = GigaChannelManager(self.session) + self.gigachannel_manager.start() + # add task for tracker checking if self.session.config.get_torrent_checking_enabled(): self.session.readable_status = STATE_START_TORRENT_CHECKER diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index a9c610d52c0..18fb7529e6b 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -47,7 +47,7 @@ def check_channels_updates(self): for channel in channels_queue: try: - if not self.session.has_download(hexlify(str(channel.infohash))): + if not self.session.has_download(str(channel.infohash)): self._logger.info("Downloading new channel version %s ver %i->%i", str(channel.public_key).encode("hex"), channel.local_version, channel.timestamp) diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index 6f45a35ccc7..acb3a9565f8 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -6928,7 +6928,6 @@ color: white; QLineEdit { -background-color: transparent; border-radius: 3px; } QLineEdit:focus, QLineEdit::hover { diff --git a/run_tribler.py b/run_tribler.py index 195ea6adaf7..e10daf2f7b3 100644 --- a/run_tribler.py +++ b/run_tribler.py @@ -1,17 +1,14 @@ import os -import random import sys import logging.config import signal -import random from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.exceptions import TriblerException from check_os import check_environment, check_free_space, error_and_exit, setup_gui_logging, \ should_kill_other_tribler_instances, enable_fault_handler, set_process_priority, \ check_and_enable_code_tracing -api_port = random.randint(10000,20000) # https://github.com/Tribler/tribler/issues/3702 @@ -71,7 +68,6 @@ def start_tribler(): priority_order = config.get_cpu_priority_order() set_process_priority(pid=os.getpid(), priority_order=priority_order) - print api_port config.set_http_api_port(int(api_port)) config.set_http_api_enabled(True) @@ -120,7 +116,7 @@ def start_tribler(): app = TriblerApplication("triblerapp", sys.argv) - window = TriblerWindow(api_port=api_port) + window = TriblerWindow() window.setWindowTitle("Tribler") app.set_activation_window(window) app.parse_sys_args(sys.argv) From 92d4cd0c6ef8414992e2d76e9a22e66cfc5169d9 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Fri, 25 Jan 2019 15:17:30 +0100 Subject: [PATCH 17/38] GUI fixes --- .../OrmBindings/channel_metadata.py | 13 ++-- Tribler/community/gigachannel/community.py | 2 +- TriblerGUI/qt_resources/mainwindow.ui | 63 +++++++++---------- TriblerGUI/widgets/lazytableview.py | 16 +++-- TriblerGUI/widgets/tablecontentmodel.py | 2 + TriblerGUI/widgets/tableiconbuttons.py | 3 + 6 files changed, 55 insertions(+), 44 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 22d407e3744..40fe24dcb10 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -391,18 +391,21 @@ def extend_to_bitmask(txt): @classmethod @db_session - def get_random_channels(cls, limit, subscribed=False): + def get_random_channels(cls, limit, only_subscribed=False): """ Fetch up to some limit of torrents from this channel :param limit: the maximum amount of torrents to fetch - :param subscribed: whether we want random channels we are subscribed to + :param only_subscribed: whether we only want random channels we are subscribed to :return: the subset of random channels we are subscribed to :rtype: list """ - return db.ChannelMetadata.select( - lambda g: g.subscribed == subscribed and g.status != LEGACY_ENTRY and g.num_entries > 0).random( - limit) + if only_subscribed: + select_lambda = lambda g: g.subscribed == True and g.status != LEGACY_ENTRY and g.num_entries > 0 + else: + select_lambda = lambda g: g.status != LEGACY_ENTRY and g.num_entries > 0 + + return db.ChannelMetadata.select(select_lambda).random(limit) @db_session def get_random_torrents(self, limit): diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index 445e8ce5d97..9891950c008 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -45,7 +45,7 @@ def send_random_to(self, peer): md_list = [] with db_session: # TODO: when the health table will be there, send popular torrents instead - channel_l = self.metadata_store.ChannelMetadata.get_random_channels(1, subscribed=True)[:] + channel_l = self.metadata_store.ChannelMetadata.get_random_channels(1, only_subscribed=True)[:] if not channel_l: return channel = channel_l[0] diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index acb3a9565f8..e41b6a4a93f 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -1252,7 +1252,7 @@ margin: 10px; - color: white; font-size: 16px; + color: white; font-size: 16px; border-top: 1px solid #555; No recommended torrents found. @@ -2436,32 +2436,6 @@ padding-right: 4px; - - - - Qt::Horizontal - - - QSizePolicy::Fixed - - - - 10 - 0 - - - - - - - - - - - 0 items - - - @@ -2739,6 +2713,32 @@ color: white; + + + + + + + 0 items + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 10 + 0 + + + + @@ -4418,8 +4418,8 @@ border-top: 1px solid #555; 0 0 - 661 - 616 + 755 + 646 @@ -7704,8 +7704,8 @@ QTabBar::tab:selected { 0 0 - 121 - 238 + 133 + 246 @@ -12423,4 +12423,3 @@ color: #eee; clicked_force_shutdown() - diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 59aa9c3f90b..3a72392aaa1 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -27,6 +27,9 @@ class TriblerContentTableView(LazyTableView): # overloading leaveEvent method could be used for that mouse_moved = pyqtSignal(QPoint, QModelIndex) + on_channel_clicked = pyqtSignal(dict) + on_torrent_clicked = pyqtSignal(QModelIndex, dict) + def __init__(self, parent=None): LazyTableView.__init__(self, parent) self.setMouseTracking(True) @@ -75,9 +78,6 @@ def on_play_request_done(_): class SubscribeButtonMixin(TriblerContentTableView): - on_subscribed_channel = pyqtSignal(QModelIndex) - on_unsubscribed_channel = pyqtSignal(QModelIndex) - def on_subscribe_control_clicked(self, index): if index.model().data_items[index.row()][u'status'] == 6: # LEGACY ENTRIES! return @@ -94,9 +94,6 @@ def on_subscribe_control_clicked(self, index): class ItemClickedMixin(TriblerContentTableView): - on_channel_clicked = pyqtSignal(dict) - on_torrent_clicked = pyqtSignal(QModelIndex, dict) - def on_table_item_clicked(self, item): column_position = self.model().column_position if (ACTION_BUTTONS in column_position and item.column() == column_position[ACTION_BUTTONS]) or \ @@ -116,6 +113,7 @@ def on_table_item_clicked(self, item): class CommitControlMixin(TriblerContentTableView): + def on_commit_control_clicked(self, index): infohash = index.model().data_items[index.row()][u'infohash'] status = index.model().data_items[index.row()][u'status'] @@ -150,6 +148,9 @@ def on_delete_button_clicked(self, index): class SearchResultsTableView(ItemClickedMixin, DownloadButtonMixin, PlayButtonMixin, SubscribeButtonMixin, TriblerContentTableView): + on_subscribed_channel = pyqtSignal(QModelIndex) + on_unsubscribed_channel = pyqtSignal(QModelIndex) + """ This table displays search results, which can be both torrents and channels. """ @@ -209,6 +210,9 @@ def resizeEvent(self, _): class ChannelsTableView(ItemClickedMixin, SubscribeButtonMixin, TriblerContentTableView): + on_subscribed_channel = pyqtSignal(QModelIndex) + on_unsubscribed_channel = pyqtSignal(QModelIndex) + """ This table displays various channels. """ diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index 81c4a13ae82..74b22b6cfc6 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -61,6 +61,7 @@ def __init__(self): RemoteTableModel.__init__(self, parent=None) self.data_items = [] self.column_position = {name: i for i, name in enumerate(self.columns)} + self.edit_enabled = False def headerData(self, num, orientation, role=None): if orientation == Qt.Horizontal and role == Qt.DisplayRole: @@ -159,3 +160,4 @@ class MyTorrentsContentModel(TorrentsContentModel): def __init__(self, channel_pk=''): TorrentsContentModel.__init__(self, channel_pk=channel_pk) self.exclude_deleted = False + self.edit_enabled = True diff --git a/TriblerGUI/widgets/tableiconbuttons.py b/TriblerGUI/widgets/tableiconbuttons.py index e7aca6f1426..686f009077a 100644 --- a/TriblerGUI/widgets/tableiconbuttons.py +++ b/TriblerGUI/widgets/tableiconbuttons.py @@ -67,3 +67,6 @@ def should_draw(self, index): class DeleteIconButton(IconButton): icon = QIcon(get_image_path("trash.svg")) + + def should_draw(self, index): + return index.model().edit_enabled From ae9731056d9a688c0d078535423326a360d3a0ef Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Mon, 28 Jan 2019 17:49:00 +0100 Subject: [PATCH 18/38] optimize db_sessions a bit --- Tribler/Core/TorrentChecker/torrent_checker.py | 3 +-- Tribler/community/gigachannel/community.py | 1 - Tribler/community/popularity/community.py | 13 +++++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index b785c13797b..904ac412180 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -15,7 +15,6 @@ from Tribler.Core.TorrentChecker.session import create_tracker_session, FakeDHTSession, UdpSocketManager from Tribler.Core.Utilities.tracker_utils import MalformedTrackerURLException from Tribler.Core.Utilities.utilities import is_valid_url -from Tribler.community.popularity.repository import TYPE_TORRENT_HEALTH from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.taskmanager import TaskManager @@ -147,7 +146,7 @@ def _task_select_tracker(self): session.add_infohash(infohash) self._logger.info(u"Selected %d new torrents to check on tracker: %s", len(infohashes), tracker_url) - return session.connect_to_tracker().addCallbacks(*self.get_callbacks_for_session(session))\ + return session.connect_to_tracker().addCallbacks(*self.get_callbacks_for_session(session)) \ .addErrback(lambda _: None) def get_callbacks_for_session(self, session): diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index 9891950c008..b52530f1ce0 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -3,7 +3,6 @@ from pony.orm import db_session from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk -from Tribler.Core.exceptions import InvalidSignatureException from Tribler.pyipv8.ipv8.community import Community from Tribler.pyipv8.ipv8.lazy_community import PacketDecodingError from Tribler.pyipv8.ipv8.messaging.payload_headers import BinMemberAuthenticationPayload diff --git a/Tribler/community/popularity/community.py b/Tribler/community/popularity/community.py index be849b9783b..7a85f47e5a6 100644 --- a/Tribler/community/popularity/community.py +++ b/Tribler/community/popularity/community.py @@ -107,16 +107,17 @@ def publish_next_content(self): payload = TorrentHealthPayload(infohash, seeders, leechers, timestamp) self.send_torrent_health_response(payload) - @db_session def publish_latest_torrents(self, peer): """ Publishes the latest torrents in local database to the given peer. """ - torrents = self.content_repository.get_top_torrents() - self.logger.info("Publishing %d torrents to peer %s", len(torrents), peer) - for torrent in torrents: - payload = TorrentHealthPayload(str(torrent.infohash), torrent.health.seeders, torrent.health.leechers, - torrent.health.last_check) + with db_session: + torrents = self.content_repository.get_top_torrents() + self.logger.info("Publishing %d torrents to peer %s", len(torrents), peer) + + to_send = [TorrentHealthPayload(str(torrent.infohash), torrent.health.seeders, torrent.health.leechers, + torrent.health.last_check) for torrent in torrents] + for payload in to_send: self.send_torrent_health_response(payload, peer=peer) def queue_content(self, content): From e496265f8868815b436d9c2413644ef58ae05d66 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Tue, 29 Jan 2019 14:59:27 +0100 Subject: [PATCH 19/38] db upgrade 72->Pony works --- .../Modules/MetadataStore/OrmBindings/misc.py | 10 + Tribler/Core/Modules/MetadataStore/store.py | 8 +- Tribler/Core/Upgrade/db71_to_pony.py | 190 ----------- Tribler/Core/Upgrade/db72_to_pony.py | 323 ++++++++++++++++++ Tribler/Core/Upgrade/upgrade.py | 95 +++++- .../Test/Core/Upgrade/test_db72_to_pony.py | 75 ++++ .../data/upgrade_databases/tribler_v29.sdb | Bin 0 -> 598016 bytes 7 files changed, 508 insertions(+), 193 deletions(-) create mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/misc.py delete mode 100644 Tribler/Core/Upgrade/db71_to_pony.py create mode 100644 Tribler/Core/Upgrade/db72_to_pony.py create mode 100644 Tribler/Test/Core/Upgrade/test_db72_to_pony.py create mode 100644 Tribler/Test/Core/data/upgrade_databases/tribler_v29.sdb diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/misc.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/misc.py new file mode 100644 index 00000000000..70d40074e23 --- /dev/null +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/misc.py @@ -0,0 +1,10 @@ +from pony import orm + +# This binding is used to store all kinds of values, like DB version, counters, etc. + +def define_binding(db): + class MiscData(db.Entity): + name = orm.PrimaryKey(str) + value = orm.Optional(str) + + return MiscData diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 760f50ee183..fe68702e7bb 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -9,7 +9,7 @@ from Tribler.Core.Category.Category import Category from Tribler.Core.Modules.MetadataStore.OrmBindings import torrent_metadata, channel_metadata, \ - torrent_state, tracker_state, channel_node + torrent_state, tracker_state, channel_node, misc from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time @@ -91,6 +91,8 @@ def __init__(self, db_filename, channels_dir, my_key): # Accessors for ORM-managed classes # self.Author = author.define_binding(self._db) + self.MiscData = misc.define_binding(self._db) + self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) @@ -121,6 +123,10 @@ def __init__(self, db_filename, channels_dir, my_key): self._db.execute(sql_add_public_key_index) self._db.execute(sql_add_infohash_index) + if create_db: + with db_session: + self.MiscData(name="db_version", value="0") + def shutdown(self): self._db.disconnect() diff --git a/Tribler/Core/Upgrade/db71_to_pony.py b/Tribler/Core/Upgrade/db71_to_pony.py deleted file mode 100644 index 632952eb155..00000000000 --- a/Tribler/Core/Upgrade/db71_to_pony.py +++ /dev/null @@ -1,190 +0,0 @@ -import base64 -import datetime -import os -from binascii import unhexlify - -import apsw -from pony.orm import db_session -from six import text_type - -from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY -from Tribler.Core.Modules.MetadataStore.store import MetadataStore -from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url, MalformedTrackerURLException -from Tribler.pyipv8.ipv8.database import database_blob -from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto - -BATCH_SIZE = 10000 - - -class DispersyToPonyMigration(object): - - def __init__(self, tribler_db, metadata_store): - self.tribler_db = tribler_db - self.mds = metadata_store - - def dispesy_cid_to_pk(self, dispersy_cid): - return database_blob(unhexlify(("%X" % dispersy_cid).zfill(128))) - - def pseudo_signature(self): - return database_blob(os.urandom(32)) - - def final_timestamp(self): - return 1 << 62 - - select_channels_sql = "Select id, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ - + "FROM Channels " \ - + "WHERE nr_torrents >= 3 " \ - + "AND name not NULL;" - - def get_old_channels(self): - connection = apsw.Connection(self.tribler_db) - cursor = connection.cursor() - - channels = [] - for id_, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute( - self.select_channels_sql): - if nr_torrents and nr_torrents > 0: - channels.append({"id_": 0, - "infohash": database_blob(os.urandom(20)), - "title": name or '', - "public_key": self.dispesy_cid_to_pk(id_), - "timestamp": self.final_timestamp(), - "votes": int(nr_favorite or 0), - "xxx": float(nr_spam or 0), - "origin_id": 0, - "signature": self.pseudo_signature(), - "skip_key_check": True, - "size": 0, - "local_version": self.final_timestamp(), - "subscribed": False, - "status": LEGACY_ENTRY, - "num_entries": int(nr_torrents or 0)}) - return channels - - select_trackers_sql = "select tracker_id, tracker, last_check, failures, is_alive from TrackerInfo" - - def get_old_trackers(self): - connection = apsw.Connection(self.tribler_db) - cursor = connection.cursor() - - trackers = {} - for tracker_id, tracker, last_check, failures, is_alive in cursor.execute(self.select_trackers_sql): - try: - tracker_url_sanitized = get_uniformed_tracker_url(tracker) - if not tracker_url_sanitized: - continue - except: - # Skip malformed trackers - continue - trackers[tracker_id] = ({"tracker": tracker_url_sanitized, - "last_check": last_check, - "failures": failures, - "is_alive": is_alive}) - return trackers - - select_torrents_sql = " FROM _ChannelTorrents ct, Torrent t, TorrentTrackerMapping mp, TrackerInfo ti WHERE ct.name NOT NULL and t.length>0 AND t.category NOT NULL AND ct.deleted_at IS NULL " + \ - " AND t.torrent_id == ct.torrent_id AND t.infohash NOT NULL AND mp.torrent_id == t.torrent_id AND ti.tracker_id == mp.tracker_id AND ti.tracker!='DHT' AND ti.tracker!='no-DHT' group by infohash ORDER BY ti.is_alive desc, ti.failures, ti.last_check desc " - - def get_old_torrents_count(self): - connection = apsw.Connection(self.tribler_db) - cursor = connection.cursor() - cursor.execute("SELECT COUNT(*) FROM (SELECT t.torrent_id " + self.select_torrents_sql + " )") - return cursor.fetchone()[0] - - def get_old_torrents(self, batch_size=BATCH_SIZE, offset=0): - connection = apsw.Connection(self.tribler_db) - cursor = connection.cursor() - - torrents = [] - for tracker_url, channel_id, name, infohash, length, creation_date, torrent_id, category, num_seeders, num_leechers, last_tracker_check in cursor.execute( - "SELECT " + \ - "ti.tracker, ct.channel_id, ct.name, t.infohash, t.length, t.creation_date, t.torrent_id, t.category, t.num_seeders, t.num_leechers, t.last_tracker_check " + \ - self.select_torrents_sql + (" LIMIT " + str(batch_size) + " OFFSET " + str(offset))): - # check if name is valid unicode data - try: - name = text_type(name) - except UnicodeDecodeError: - continue - - try: - if len(base64.decodestring(infohash)) != 20: - continue - infohash = base64.decodestring(infohash) - torrents.append( - ({ - "status": LEGACY_ENTRY, - "infohash": infohash, - "timestamp": int(torrent_id or 0), - "size": int(length or 0), - "torrent_date": datetime.datetime.utcfromtimestamp(creation_date or 0), - "title": name or '', - "tags": category or '', - "id_": torrent_id or 0, - "origin_id": 0, - "tracker_info": tracker_url, - "public_key": self.dispesy_cid_to_pk(channel_id), - "signature": self.pseudo_signature(), - "xxx": int(category == u'xxx'), - "skip_key_check": True}, - { - "seeders": int(num_seeders or 0), - "leechers": int(num_leechers or 0), - "last_check": int(last_tracker_check or 0)})) - except: - continue - - - return torrents - - -if __name__ == "__main__": - my_key = default_eccrypto.generate_key(u"curve25519") - mds = MetadataStore("/tmp/metadata.db", "/tmp", my_key) - d = DispersyToPonyMigration("/tmp/tribler.sdb", mds) - # old_channels = d.get_old_channels() - old_trackers = d.get_old_trackers() - - start = datetime.datetime.utcnow() - x = 0 - batch_size = 1000 - total_to_convert = d.get_old_torrents_count() - old_torrents = d.get_old_torrents() - - while True: - old_torrents = d.get_old_torrents(batch_size=batch_size, offset=x) - if not old_torrents: - break - with db_session: - for (t, h) in old_torrents: - try: - m = mds.TorrentMetadata(**t) - except: - continue - - x += batch_size - print ("%i/%i" % (x, total_to_convert)) - - with db_session: - old_channels = d.get_old_channels() - for c in old_channels: - try: - mds.ChannelMetadata(**c) - except: - continue - - with db_session: - for c in mds.ChannelMetadata.select()[:]: - c.num_entries = c.contents_len - if c.num_entries == 0: - c.delete() - - stop = datetime.datetime.utcnow() - elapsed = (stop-start).total_seconds() - - print ("%i entries converted in %i seconds (%i e/s)" % (total_to_convert, int(elapsed), int(total_to_convert/elapsed))) - -# 1 - Move Trackers (URLs) -# 2 - Move torrent Infohashes -# 3 - Move Infohash-Tracker relationships -# 4 - Move Metadata, based on Infohashes -# 5 - Move Channels diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py new file mode 100644 index 00000000000..c3038e50a01 --- /dev/null +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -0,0 +1,323 @@ +import base64 +import datetime +import os +from binascii import unhexlify + +import apsw +from pony import orm +from pony.orm import db_session +from six import text_type + +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY, NEW +from Tribler.Core.Modules.MetadataStore.serialization import REGULAR_TORRENT +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url +from Tribler.pyipv8.ipv8.database import database_blob +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + +BATCH_SIZE = 10000 + +DISCOVERED_CONVERSION_STARTED = "discovered_conversion_started" +CHANNELS_CONVERSION_STARTED = "channels_conversion_started" +TRACKERS_CONVERSION_STARTED = "trackers_conversion_started" +PERSONAL_CONVERSION_STARTED = "personal_conversion_started" +CONVERSION_FINISHED = "conversion_finished" +CONVERSION_FROM_72 = "conversion_from_72" + + +def dispesy_cid_to_pk(dispersy_cid): + return database_blob(unhexlify(("%X" % dispersy_cid).zfill(128))) + + +def pseudo_signature(): + return database_blob(os.urandom(32)) + + +def final_timestamp(): + return 1 << 62 + + +class DispersyToPonyMigration(object): + select_channels_sql = "Select id, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam " \ + + "FROM Channels " \ + + "WHERE nr_torrents >= 3 " \ + + "AND name not NULL;" + + select_trackers_sql = "select tracker_id, tracker, last_check, failures, is_alive from TrackerInfo" + + select_full = "SELECT" \ + " (select ti.tracker from TorrentTrackerMapping ttm, TrackerInfo ti where ttm.torrent_id == t.torrent_id and ttm.tracker_id == ti.tracker_id and ti.tracker != 'DHT' and ti.tracker != 'http://retracker.local/announce' order by ti.is_alive asc, ti.failures desc, ti.last_check asc), " \ + " ct.channel_id, ct.name, t.infohash, t.length, t.creation_date, t.torrent_id, t.category, t.num_seeders, t.num_leechers, t.last_tracker_check " \ + "FROM _ChannelTorrents ct, Torrent t WHERE ct.name NOT NULL and t.length > 0 AND t.category NOT NULL AND ct.deleted_at IS NULL AND t.torrent_id == ct.torrent_id AND t.infohash NOT NULL " + + select_torrents_sql = " FROM _ChannelTorrents ct, Torrent t WHERE " + \ + "ct.name NOT NULL and t.length>0 AND t.category NOT NULL AND ct.deleted_at IS NULL " + \ + " AND t.torrent_id == ct.torrent_id AND t.infohash NOT NULL " + + def __init__(self, tribler_db, metadata_store, notifier_callback=None): + self.notifier_callback = notifier_callback + self.tribler_db = tribler_db + self.mds = metadata_store + + self.personal_channel_id = None + self.personal_channel_title = None + + def initialize(self): + try: + self.personal_channel_id, self.personal_channel_title = self.get_personal_channel_id_title() + self.personal_channel_title = self.personal_channel_title[:200] # limit the title size + except: + print ("No personal channel found") + + def get_old_channels(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + + channels = [] + for id_, name, dispersy_cid, modified, nr_torrents, nr_favorite, nr_spam in cursor.execute( + self.select_channels_sql): + if nr_torrents and nr_torrents > 0: + channels.append({"id_": 0, + "infohash": database_blob(os.urandom(20)), + "title": name or '', + "public_key": dispesy_cid_to_pk(id_), + "timestamp": final_timestamp(), + "votes": int(nr_favorite or 0), + "xxx": float(nr_spam or 0), + "origin_id": 0, + "signature": pseudo_signature(), + "skip_key_check": True, + "size": 0, + "local_version": final_timestamp(), + "subscribed": False, + "status": LEGACY_ENTRY, + "num_entries": int(nr_torrents or 0)}) + return channels + + def get_personal_channel_id_title(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + cursor.execute('SELECT id,name FROM Channels WHERE peer_id ISNULL LIMIT 1') + return cursor.fetchone() + + def get_old_trackers(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + + trackers = {} + for tracker_id, tracker, last_check, failures, is_alive in cursor.execute(self.select_trackers_sql): + try: + tracker_url_sanitized = get_uniformed_tracker_url(tracker) + if not tracker_url_sanitized: + continue + except: + # Skip malformed trackers + continue + trackers[tracker_url_sanitized] = ({ + "last_check": last_check, + "failures": failures, + "alive": is_alive}) + return trackers + + def get_old_torrents_count(self, personal_channel_only=False): + personal_channel_filter = "" + if self.personal_channel_id: + personal_channel_filter = " AND ct.channel_id " + \ + (" == " if personal_channel_only else " != ") + \ + (" %i " % self.personal_channel_id) + + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + cursor.execute("SELECT COUNT(*) FROM (SELECT t.torrent_id " + self.select_torrents_sql + \ + personal_channel_filter + "group by infohash )") + return cursor.fetchone()[0] + + def get_personal_channel_torrents_count(self): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + cursor.execute("SELECT COUNT(*) FROM (SELECT t.torrent_id " + self.select_torrents_sql + \ + (" AND ct.channel_id == %s " % self.personal_channel_id) + \ + " group by infohash )") + return cursor.fetchone()[0] + + def get_old_torrents(self, personal_channel_only=False, batch_size=BATCH_SIZE, offset=0, + sign=False): + connection = apsw.Connection(self.tribler_db) + cursor = connection.cursor() + + personal_channel_filter = "" + if self.personal_channel_id: + personal_channel_filter = " AND ct.channel_id " + \ + (" == " if personal_channel_only else " != ") + \ + (" %i " % self.personal_channel_id) + + torrents = [] + for tracker_url, channel_id, name, infohash, length, creation_date, torrent_id, category, num_seeders, num_leechers, last_tracker_check in cursor.execute( + self.select_full + personal_channel_filter + " group by infohash" + ( + " LIMIT " + str(batch_size) + " OFFSET " + str(offset))): + # check if name is valid unicode data + try: + name = text_type(name) + except UnicodeDecodeError: + continue + + try: + if len(base64.decodestring(infohash)) != 20: + continue + infohash = base64.decodestring(infohash) + + torrent_dict = { + "status": NEW, + "infohash": infohash, + "size": int(length or 0), + "torrent_date": datetime.datetime.utcfromtimestamp(creation_date or 0), + "title": name or '', + "tags": category or '', + "id_": torrent_id or 0, + "origin_id": 0, + "tracker_info": tracker_url or '', + "xxx": int(category == u'xxx')} + if not sign: + torrent_dict.update({ + "timestamp": int(torrent_id or 0), + "status": LEGACY_ENTRY, + "public_key": dispesy_cid_to_pk(channel_id), + "signature": pseudo_signature(), + "skip_key_check": True}) + + health_dict = { + "seeders": int(num_seeders or 0), + "leechers": int(num_leechers or 0), + "last_check": int(last_tracker_check or 0)} + torrents.append((torrent_dict, health_dict)) + except: + continue + + return torrents + + def convert_personal_channel(self): + # Reflect conversion state + with db_session: + v = self.mds.MiscData.get(name=CONVERSION_FROM_72) + if v: + if v.value == PERSONAL_CONVERSION_STARTED: + # Just drop the entries from the previous try + + my_channel = self.mds.ChannelMetadata.get_my_channel() + for g in my_channel.contents_list: + g.delete() + my_channel.delete() + else: + v.set(value=PERSONAL_CONVERSION_STARTED) + + else: + self.mds.MiscData(name=CONVERSION_FROM_72, value=PERSONAL_CONVERSION_STARTED) + + if not self.personal_channel_id or not self.get_personal_channel_torrents_count(): + return + + old_torrents = self.get_old_torrents(personal_channel_only=True, sign=True) + with db_session: + my_channel = self.mds.ChannelMetadata.create_channel(title=self.personal_channel_title, description='') + for (t, h) in old_torrents: + try: + md = self.mds.TorrentMetadata(**t) + md.parents.add(my_channel) + except: + continue + my_channel.commit_channel_torrent() + + def convert_discovered_torrents(self): + + offset = 0 + # Reflect conversion state + with db_session: + v = self.mds.MiscData.get(name=CONVERSION_FROM_72) + if v: + offset = orm.count( + g for g in self.mds.TorrentMetadata if g.status == LEGACY_ENTRY and g.metadata_type == REGULAR_TORRENT) + v.set(value=DISCOVERED_CONVERSION_STARTED) + else: + self.mds.MiscData(name=CONVERSION_FROM_72, value=DISCOVERED_CONVERSION_STARTED) + + start = datetime.datetime.utcnow() + x = 0 + offset + batch_size = 1000 + total_to_convert = self.get_old_torrents_count() + + while True: + old_torrents = self.get_old_torrents(batch_size=batch_size, offset=x) + if not old_torrents: + break + with db_session: + for (t, h) in old_torrents: + try: + self.mds.TorrentMetadata(**t) + except: + continue + + x += batch_size + if self.notifier_callback: + self.notifier_callback("%i/%i" % (x, total_to_convert)) + + stop = datetime.datetime.utcnow() + elapsed = (stop - start).total_seconds() + + if self.notifier_callback: + self.notifier_callback("%i entries converted in %i seconds (%i e/s)" % (x, int(elapsed), int(x / elapsed))) + + def convert_discovered_channels(self): + # Reflect conversion state + with db_session: + v = self.mds.MiscData.get(name=CONVERSION_FROM_72) + if v: + if v.value == CHANNELS_CONVERSION_STARTED: + # Just drop the entries from the previous try + orm.delete(g for g in self.mds.ChannelMetadata if g.status == LEGACY_ENTRY) + else: + v.set(value=CHANNELS_CONVERSION_STARTED) + else: + self.mds.MiscData(name=CONVERSION_FROM_72, value=CHANNELS_CONVERSION_STARTED) + + + + with db_session: + old_channels = self.get_old_channels() + for c in old_channels: + try: + self.mds.ChannelMetadata(**c) + except: + continue + + with db_session: + for c in self.mds.ChannelMetadata.select()[:]: + c.num_entries = c.contents_len + if c.num_entries == 0: + c.delete() + + def update_trackers_info(self): + old_trackers = self.get_old_trackers() + with db_session: + trackers = self.mds.TrackerState.select()[:] + for tracker in trackers: + if tracker.url in old_trackers: + tracker.set(**old_trackers[tracker.url]) + + def mark_conversion_finished(self): + with db_session: + v = self.mds.MiscData.get(name=CONVERSION_FROM_72) + if v: + v.set(value=CONVERSION_FINISHED) + else: + self.mds.MiscData(name=CONVERSION_FROM_72, value=CONVERSION_FINISHED) + + +if __name__ == "__main__": + my_key = default_eccrypto.generate_key(u"curve25519") + mds = MetadataStore("/tmp/metadata.db", "/tmp", my_key) + d = DispersyToPonyMigration("/tmp/tribler.sdb", mds) + + d.initialize() + d.convert_personal_channel() + d.convert_discovered_channels() + d.update_trackers_info() diff --git a/Tribler/Core/Upgrade/upgrade.py b/Tribler/Core/Upgrade/upgrade.py index d28dbc798c2..aa212df22cc 100644 --- a/Tribler/Core/Upgrade/upgrade.py +++ b/Tribler/Core/Upgrade/upgrade.py @@ -1,8 +1,13 @@ from __future__ import absolute_import import logging +import os +import apsw + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.Upgrade.config_converter import convert_config_to_tribler71 +from Tribler.Core.Upgrade.db72_to_pony import DispersyToPonyMigration, CONVERSION_FROM_72, CONVERSION_FINISHED from Tribler.Core.simpledefs import NTFY_FINISHED, NTFY_STARTED, NTFY_UPGRADER, NTFY_UPGRADER_TICK @@ -27,14 +32,100 @@ def run(self): """ self.notify_starting() - self.upgrade_config_to_71() - + self.upgrade_72_to_pony() + # self.upgrade_config_to_71() self.notify_done() def update_status(self, status_text): self.session.notifier.notify(NTFY_UPGRADER_TICK, NTFY_STARTED, None, status_text) self.current_status = status_text + def upgrade_72_to_pony(self): + old_database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'tribler.sdb') + old_database_exists = os.path.exists(old_database_path) + + if not old_database_exists: + # no old DB to upgrade + return + + # Check the old DB version + try: + connection = apsw.Connection(old_database_path) + cursor = connection.cursor() + cursor.execute('SELECT value FROM MyInfo WHERE entry == "version"') + version = int(cursor.fetchone()[0]) + if version != 29: + return + except: + self._logger.error("Can't open the old tribler.sdb file") + return + + new_database_path = os.path.join(self.session.config.get_state_dir(), 'sqlite', 'metadata.db') + new_database_exists = os.path.exists(new_database_path) + state = None # Previous conversion state + if new_database_exists: + # Check for the old experimental version database + # ACHTUNG!!! NUCLEAR OPTION!!! DO NOT MESS WITH IT!!! + delete_old_db = False + try: + connection = apsw.Connection(new_database_path) + cursor = connection.cursor() + cursor.execute("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'MiscData'") + result = cursor.fetchone() + delete_old_db = not bool(result[0] if result else False) + except: + return + finally: + try: + connection.close() + except: + pass + if delete_old_db: + # We're looking at the old experimental version database. Delete it. + os.unlink(new_database_path) + new_database_exists = False + + if new_database_exists: + # Let's check if we converted all/some entries + try: + cursor.execute('SELECT value FROM MiscData WHERE name == "db_version"') + version = int(cursor.fetchone()[0]) + if version != 0: + connection.close() + return + cursor.execute('SELECT value FROM MiscData WHERE name == "%s"' % CONVERSION_FROM_72) + result = cursor.fetchone() + if result: + state = result[0] + if state == CONVERSION_FINISHED: + connection.close() + return + except: + self._logger.error("Can't open the new metadata.db file") + return + finally: + connection.close() + + channels_dir = os.path.join(self.session.config.get_chant_channels_dir()) + # We have to create the Metadata Store object because the LaunchManyCore has not been started yet + mds = MetadataStore(new_database_path, channels_dir, self.session.trustchain_keypair) + d = DispersyToPonyMigration(old_database_path, mds, self.update_status) + + d.initialize() + + d.convert_discovered_torrents() + + d.convert_discovered_channels() + + d.convert_personal_channel() + + d.update_trackers_info() + + d.mark_conversion_finished() + # Notify GigaChannel Manager? + + mds.shutdown() + def upgrade_config_to_71(self): """ This method performs actions necessary to upgrade the configuration files to Tribler 7.1. diff --git a/Tribler/Test/Core/Upgrade/test_db72_to_pony.py b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py new file mode 100644 index 00000000000..0b0b4caf512 --- /dev/null +++ b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py @@ -0,0 +1,75 @@ +import os + +from pony.orm import db_session +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Upgrade.db72_to_pony import DispersyToPonyMigration +from Tribler.Test.Core.base_test import TriblerCoreTest +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + + +class TestUpgradeDB72ToPony(TriblerCoreTest): + OLD_DB_SAMPLE = os.path.join(os.path.abspath(os.path.dirname(os.path.realpath(__file__))), '..', 'data', + 'upgrade_databases', 'tribler_v29.sdb') + + @inlineCallbacks + def setUp(self): + yield super(TestUpgradeDB72ToPony, self).setUp() + + self.my_key = default_eccrypto.generate_key(u"curve25519") + mds_db = os.path.join(self.session_base_dir, 'test.db') + mds_channels_dir = self.session_base_dir + + self.mds = MetadataStore(mds_db, mds_channels_dir, self.my_key) + self.m = DispersyToPonyMigration(self.OLD_DB_SAMPLE, self.mds) + + @inlineCallbacks + def tearDown(self): + self.mds.shutdown() + yield super(TestUpgradeDB72ToPony, self).tearDown() + + def test_get_personal_channel_title(self): + self.m.initialize() + self.assertTrue(self.m.personal_channel_title) + + def test_get_old_torrents_count(self): + self.m.initialize() + self.assertEqual(self.m.get_old_torrents_count(), 19) + + def test_get_personal_torrents_count(self): + self.m.initialize() + self.assertEqual(self.m.get_personal_channel_torrents_count(), 2) + + def test_convert_personal_channel(self): + self.m.initialize() + self.m.convert_personal_channel() + my_channel = self.mds.ChannelMetadata.get_my_channel() + self.assertEqual(len(my_channel.contents_list), 2) + self.assertEqual(my_channel.num_entries, 2) + for t in my_channel.contents_list: + self.assertTrue(t.has_valid_signature()) + self.assertTrue(my_channel.has_valid_signature()) + self.assertEqual(self.m.personal_channel_title[:200], my_channel.title) + + def test_convert_all_channels(self): + self.m.initialize() + self.m.convert_discovered_channels() + chans = self.mds.ChannelMetadata.get_channels() + self.assertEqual(len(chans), 2) + for c in chans[0]: + self.assertNotEqual(self.m.personal_channel_title[:200], c.title) + self.assertEqual(c.status, LEGACY_ENTRY) + self.assertTrue(c.contents_list) + for t in c.contents_list: + self.assertEqual(t.status, LEGACY_ENTRY) + + @db_session + def test_update_trackers(self): + self.m.initialize() + tr = self.mds.TrackerState(url="http://ipv6.torrent.ubuntu.com:6969/announce") + self.m.update_trackers_info() + self.assertEqual(tr.failures, 2) + self.assertEqual(tr.alive, True) + self.assertEqual(tr.last_check, 1548776649) diff --git a/Tribler/Test/Core/data/upgrade_databases/tribler_v29.sdb b/Tribler/Test/Core/data/upgrade_databases/tribler_v29.sdb new file mode 100644 index 0000000000000000000000000000000000000000..429caabd0ab1cd9c0fc036a873c3ef15f1f1d347 GIT binary patch literal 598016 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 literal 0 HcmV?d00001 From 43b96d3e9a54c6d782f776d6bd1f95d36060172f Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 30 Jan 2019 22:36:54 +0100 Subject: [PATCH 20/38] change db and blob format a bit fixes to FTS --- .../OrmBindings/channel_metadata.py | 16 ++++- .../MetadataStore/OrmBindings/channel_node.py | 8 +-- .../OrmBindings/torrent_metadata.py | 19 +++--- .../Modules/MetadataStore/serialization.py | 60 ++++++++++-------- Tribler/Core/Modules/MetadataStore/store.py | 18 ++++-- Tribler/Core/Upgrade/db72_to_pony.py | 8 ++- .../Core/Modules/MetadataStore/test_store.py | 2 +- .../MetadataStore/test_torrent_metadata.py | 9 +++ .../000000000002.mdblob.lz4 | Bin 0 -> 283 bytes .../000000000006.mdblob.lz4 | Bin 0 -> 538 bytes .../000000000007.mdblob.lz4 | Bin 0 -> 211 bytes .../000000000009.mdblob.lz4 | Bin 0 -> 223 bytes .../000000000002.mdblob.lz4 | Bin 283 -> 0 bytes .../000000000006.mdblob.lz4 | Bin 539 -> 0 bytes .../000000000007.mdblob.lz4 | Bin 211 -> 0 bytes .../000000000009.mdblob.lz4 | Bin 223 -> 0 bytes .../Core/data/sample_channel/channel.mdblob | Bin 236 -> 244 bytes .../Core/data/sample_channel/channel.torrent | 2 +- .../data/sample_channel/channel_upd.mdblob | Bin 236 -> 244 bytes .../data/sample_channel/channel_upd.torrent | 2 +- 20 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000002.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000006.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000007.mdblob.lz4 create mode 100644 Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000009.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000002.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 delete mode 100644 Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 40fe24dcb10..fbf2eb8f13e 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -79,6 +79,7 @@ class ChannelMetadata(db.TorrentMetadata): # Serializable num_entries = orm.Optional(int, size=64, default=0) + start_timestamp = orm.Optional(int, size=64, default=0) # Local subscribed = orm.Optional(bool, default=False) @@ -150,9 +151,16 @@ def consolidate_channel_torrent(self): # We only remove mdblobs and leave the rest as it is if filename.endswith(BLOB_EXTENSION) or filename.endswith(BLOB_EXTENSION + '.lz4'): os.unlink(file_path) + + # Channel should get a new starting timestamp and its contents should get higher timestamps + start_timestamp = self._clock.tick() for g in self.contents: - g.status = NEW - self.commit_channel_torrent() + if g.status == COMMITTED: + g.status = NEW + g.timestamp = self._clock.tick() + g.sign() + + self.commit_channel_torrent(new_start_timestamp=start_timestamp) def update_channel_torrent(self, metadata_list): """ @@ -186,7 +194,7 @@ def update_channel_torrent(self, metadata_list): return {"infohash": infohash, "num_entries": self.contents_len, "timestamp": new_timestamp, "torrent_date": torrent_date} - def commit_channel_torrent(self): + def commit_channel_torrent(self, new_start_timestamp=None): """ Collect new/uncommitted and marked for deletion metadata entries, commit them to a channel torrent and remove the obsolete entries if the commit succeeds. @@ -204,6 +212,8 @@ def commit_channel_torrent(self): "Error during channel torrent commit, not going to garbage collect the channel. Channel %s", str(self.public_key).encode("hex")) else: + if new_start_timestamp: + update_dict['start_timestamp'] = new_start_timestamp self.update_metadata(update_dict) self.local_version = self.timestamp # Change status of committed metadata and clean up obsolete TODELETE entries diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index eb3c05ccb08..c5d81418d92 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -47,19 +47,19 @@ class ChannelNode(db.Entity): rowid = orm.PrimaryKey(int, size=64, auto=True) # Serializable - metadata_type = orm.Discriminator(int) + metadata_type = orm.Discriminator(int, size=16) + reserved_flags = orm.Optional(int, size=16, default=0) origin_id = orm.Optional(int, size=64, default=0) public_key = orm.Required(database_blob) id_ = orm.Required(int, size=64) - orm.composite_index(public_key, id_) # Requires Pony 0.7.7+ with Python2 + orm.composite_index(public_key, id_) timestamp = orm.Required(int, size=64, default=0) - signature = orm.Required(database_blob, unique=True) # Local - addition_timestamp = orm.Optional(datetime, default=datetime.utcnow) + added_on = orm.Optional(datetime, default=datetime.utcnow) status = orm.Optional(int, default=COMMITTED) parents = orm.Set('ChannelNode', reverse='children') diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 1bf586d49b5..48c117fc328 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -49,32 +49,34 @@ def add_tracker(self, tracker_url): def before_update(self): self.add_tracker(self.tracker_info) - def get_magnet(self): return ("magnet:?xt=urn:btih:%s&dn=%s" % (str(self.infohash).encode('hex'), self.title)) + \ ("&tr=%s" % self.tracker_info if self.tracker_info else "") @classmethod - def search_keyword(cls, query, entry_type=None, lim=100): + def search_keyword(cls, query, lim=100): # Requires FTS5 table "FtsIndex" to be generated and populated. # FTS table is maintained automatically by SQL triggers. # BM25 ranking is embedded in FTS5. # Sanitize FTS query - if not query: + if not query or query == "*": return [] - if query.endswith("*"): - query = "\"" + query[:-1] + "\"" + "*" - else: - query = "\"" + query + "\"" + # FIXME: !!!! DO PROPER SQL SANITIZING !!!! + query = unicode(query) + query = query.translate({ord(u'"'): u"\"\"", ord(u"'"): u"\'\'"}) + query = "("+query+")" fts_ids = raw_sql( - "SELECT rowid FROM FtsIndex WHERE FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT %d" % lim) + 'SELECT rowid FROM FtsIndex WHERE FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT $lim') return cls.select(lambda g: g.rowid in fts_ids) @classmethod def get_auto_complete_terms(cls, keyword, max_terms, limit=100): + if not keyword: + return [] + with db_session: result = cls.search_keyword(keyword + "*", lim=limit)[:] titles = [g.title.lower() for g in result] @@ -158,5 +160,4 @@ def metadata_conflicting(self, b): return True return False - return TorrentMetadata diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index 3713042398c..af19c8bba06 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -56,7 +56,7 @@ class UnknownBlobTypeException(Exception): def read_payload_with_offset(data, offset=0): # First we have to determine the actual payload type - metadata_type = struct.unpack_from('>I', database_blob(data), offset=offset)[0] + metadata_type = struct.unpack_from('>H', database_blob(data), offset=offset)[0] if metadata_type == DELETED: return DeletedMetadataPayload.from_signed_blob_with_offset(data, offset=offset) elif metadata_type == REGULAR_TORRENT: @@ -77,11 +77,12 @@ class SignedPayload(Payload): Payload for metadata. """ - format_list = ['I', '64s'] + format_list = ['H', 'H', '64s'] - def __init__(self, metadata_type, public_key, **kwargs): + def __init__(self, metadata_type, reserved_flags, public_key, **kwargs): super(SignedPayload, self).__init__() self.metadata_type = metadata_type + self.reserved_flags = reserved_flags self.public_key = str(public_key) self.signature = str(kwargs["signature"]) if "signature" in kwargs else EMPTY_SIG @@ -111,13 +112,14 @@ def has_valid_signature(self): self.signature) def to_pack_list(self): - data = [('I', self.metadata_type), + data = [('H', self.metadata_type), + ('H', self.reserved_flags), ('64s', self.public_key)] return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, **kwargs): - return SignedPayload(metadata_type, public_key, **kwargs) + def from_unpack_list(cls, metadata_type, reserved_flags, public_key, **kwargs): + return SignedPayload(metadata_type, reserved_flags, public_key, **kwargs) @classmethod def from_signed_blob(cls, data, check_signature=True): @@ -137,6 +139,7 @@ def from_signed_blob_with_offset(cls, data, check_signature=True, offset=0): def to_dict(self): return { "metadata_type": self.metadata_type, + "reserved_flags": self.reserved_flags, "public_key": self.public_key, "signature": self.signature } @@ -157,13 +160,13 @@ def from_file(cls, filepath): class ChannelNodePayload(SignedPayload): format_list = SignedPayload.format_list + ['Q', 'Q', 'Q'] - def __init__(self, metadata_type, public_key, + def __init__(self, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, **kwargs): self.id_ = id_ self.origin_id = origin_id self.timestamp = timestamp - super(ChannelNodePayload, self).__init__(metadata_type, public_key, + super(ChannelNodePayload, self).__init__(metadata_type, reserved_flags, public_key, **kwargs) def to_pack_list(self): @@ -174,10 +177,10 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, + def from_unpack_list(cls, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, **kwargs): - return ChannelNodePayload(metadata_type, public_key, + return ChannelNodePayload(metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, **kwargs) @@ -197,7 +200,7 @@ class TorrentMetadataPayload(ChannelNodePayload): """ format_list = ChannelNodePayload.format_list + ['20s', 'Q', 'I', 'varlenI', 'varlenI', 'varlenI'] - def __init__(self, metadata_type, public_key, + def __init__(self, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): @@ -207,7 +210,7 @@ def __init__(self, metadata_type, public_key, self.title = title.decode('utf-8') if type(title) == str else title self.tags = tags.decode('utf-8') if type(tags) == str else tags self.tracker_info = tracker_info.decode('utf-8') if type(tracker_info) == str else tracker_info - super(TorrentMetadataPayload, self).__init__(metadata_type, public_key, + super(TorrentMetadataPayload, self).__init__(metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, **kwargs) @@ -222,10 +225,10 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, + def from_unpack_list(cls, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs): - return TorrentMetadataPayload(metadata_type, public_key, + return TorrentMetadataPayload(metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) @@ -252,15 +255,16 @@ class ChannelMetadataPayload(TorrentMetadataPayload): """ Payload for metadata that stores a channel. """ - format_list = TorrentMetadataPayload.format_list + ['Q'] + format_list = TorrentMetadataPayload.format_list + ['Q'] + ['Q'] - def __init__(self, metadata_type, public_key, + def __init__(self, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, - num_entries, + num_entries, start_timestamp, **kwargs): self.num_entries = num_entries - super(ChannelMetadataPayload, self).__init__(metadata_type, public_key, + self.start_timestamp = start_timestamp + super(ChannelMetadataPayload, self).__init__(metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, **kwargs) @@ -268,23 +272,25 @@ def __init__(self, metadata_type, public_key, def to_pack_list(self): data = super(ChannelMetadataPayload, self).to_pack_list() data.append(('Q', self.num_entries)) + data.append(('Q', self.start_timestamp)) return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, + def from_unpack_list(cls, metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, - num_entries, + num_entries, start_timestamp, **kwargs): - return ChannelMetadataPayload(metadata_type, public_key, + return ChannelMetadataPayload(metadata_type, reserved_flags, public_key, id_, origin_id, timestamp, infohash, size, torrent_date, title, tags, tracker_info, - num_entries, + num_entries, start_timestamp, **kwargs) def to_dict(self): dct = super(ChannelMetadataPayload, self).to_dict() - dct.update({"num_entries": self.num_entries}) + dct.update({"num_entries": self.num_entries, + "start_timestamp": self.start_timestamp}) return dct @@ -294,11 +300,11 @@ class DeletedMetadataPayload(SignedPayload): """ format_list = SignedPayload.format_list + ['64s'] - def __init__(self, metadata_type, public_key, + def __init__(self, metadata_type, reserved_flags, public_key, delete_signature, **kwargs): self.delete_signature = str(delete_signature) - super(DeletedMetadataPayload, self).__init__(metadata_type, public_key, + super(DeletedMetadataPayload, self).__init__(metadata_type, reserved_flags, public_key, **kwargs) def to_pack_list(self): @@ -307,10 +313,10 @@ def to_pack_list(self): return data @classmethod - def from_unpack_list(cls, metadata_type, public_key, + def from_unpack_list(cls, metadata_type, reserved_flags, public_key, delete_signature, **kwargs): - return DeletedMetadataPayload(metadata_type, public_key, + return DeletedMetadataPayload(metadata_type, reserved_flags, public_key, delete_signature, **kwargs) diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index fe68702e7bb..ff962678c0b 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -2,6 +2,7 @@ import logging import os +from datetime import datetime import lz4.frame from pony import orm @@ -12,7 +13,7 @@ torrent_state, tracker_state, channel_node, misc from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ - CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time + CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time, time2int # This table should never be used from ORM directly. # It is created as a VIRTUAL table by raw SQL and # maintained by SQL triggers. @@ -22,7 +23,7 @@ sql_create_fts_table = """ CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING FTS5 - (title, tags, content='ChannelNode', + (title, tags, content='ChannelNode', prefix = '2 3 4 5', tokenize='porter unicode61 remove_diacritics 1');""" sql_add_fts_trigger_insert = """ @@ -59,7 +60,11 @@ class DiscreteClock(object): # Horribly inefficient and stupid, but works def __init__(self, filename=None): self.filename = filename - self.clock = 0 + # This is a stupid workaround for people who reinstall Tribler + # and lose their database. We don't know what was their channel + # clock before, but at least we can assume that they were not + # adding to it 1000 torrents per second constantly... + self.clock = time2int(datetime.utcnow())*1000 # Read the clock from the disk if the filename is given if self.filename and os.path.isfile(self.filename): with open(self.filename, 'rb') as f: @@ -157,7 +162,12 @@ def process_channel_dir(self, dirname, channel_id): if blob_sequence_number is not None: # Skip blobs containing data we already have and those that are # ahead of the channel version known to us - if blob_sequence_number <= channel.local_version or blob_sequence_number > channel.timestamp: + # ==================| channel data |=== + # ===start_timestamp|---local_version----timestamp|=== + # local_version is essentially a cursor pointing into the current state of update process + if blob_sequence_number <= channel.start_timestamp or \ + blob_sequence_number <= channel.local_version or \ + blob_sequence_number > channel.timestamp: continue try: self.process_mdblob_file(full_filename) diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py index c3038e50a01..fd42efd6573 100644 --- a/Tribler/Core/Upgrade/db72_to_pony.py +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -207,8 +207,10 @@ def convert_personal_channel(self): for g in my_channel.contents_list: g.delete() my_channel.delete() - else: + elif v.value == DISCOVERED_CONVERSION_STARTED: v.set(value=PERSONAL_CONVERSION_STARTED) + else: + return else: self.mds.MiscData(name=CONVERSION_FROM_72, value=PERSONAL_CONVERSION_STARTED) @@ -216,6 +218,10 @@ def convert_personal_channel(self): if not self.personal_channel_id or not self.get_personal_channel_torrents_count(): return + # Make sure there is nothing left of old personal channel, just in case + if self.mds.ChannelMetadata.get_my_channel(): + return + old_torrents = self.get_old_torrents(personal_channel_only=True, sign=True) with db_session: my_channel = self.mds.ChannelMetadata.create_channel(title=self.personal_channel_title, description='') diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 93cb0bbe925..ea529599390 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -20,7 +20,7 @@ def make_wrong_payload(filename): key = default_eccrypto.generate_key(u"curve25519") - metadata_payload = SignedPayload(666, database_blob(key.pub().key_to_bin()[10:]), signature='\x00'*64, skip_key_check=True) + metadata_payload = SignedPayload(666, 0, database_blob(key.pub().key_to_bin()[10:]), signature='\x00'*64, skip_key_check=True) with open(filename, 'wb') as output_file: output_file.write(''.join(metadata_payload.serialized())) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index f5ef45be2d7..b5500404329 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -68,8 +68,16 @@ def test_search_keyword(self): dict(self.torrent_template, title="xoxoxo bar", tags="video")) self.mds.TorrentMetadata.from_dict( dict(self.torrent_template, title="xoxoxo bar", tags="audio")) + self.mds.TorrentMetadata.from_dict( + dict(self.torrent_template, title=u"\"", tags="audio")) + self.mds.TorrentMetadata.from_dict( + dict(self.torrent_template, title=u"\'", tags="audio")) orm.flush() + # Ensure that the thing is able to process all types of quotes + self.mds.TorrentMetadata.search_keyword("\'")[:] + self.mds.TorrentMetadata.search_keyword("\"")[:] + # Search for torrents with the keyword 'foo', it should return one result results = self.mds.TorrentMetadata.search_keyword("foo")[:] self.assertEqual(len(results), 1) @@ -113,6 +121,7 @@ def test_wildcard_search(self): self.assertEqual(0, len(self.mds.TorrentMetadata.search_keyword("*")[:])) self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("foobl*")[:])) self.assertEqual(2, len(self.mds.TorrentMetadata.search_keyword("foo*")[:])) + self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("(\"12*\" + \"foobl*\")")[:])) @db_session def test_stemming_search(self): diff --git a/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000002.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000002.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..3328257a00d734ff1d0e425f1d7c13988e20075f GIT binary patch literal 283 zcmV+$0p$J!B25@TK)?(E008kf0W1Iji9UyIJqhr;hrg8rYdu~jUsmXU#g{e+2=Km5 zpfHvy?O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`aYcSP>)-0{&>}xxbtY3x@#^@PXiozAL+2!u-b>qc!y6UsH3z hzH#WcCi(9(Enns~z-90?s)cv1F5ETUpb7v00017YY}x<- literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000006.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000006.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..609cd7635d329d75403182d0c495974a25a8f3e1 GIT binary patch literal 538 zcmV+#0_FV#B25@TK)?n8008kf0W1Iji9UyIJqhr;hrg8rYdu~jUsmXU#g{e+2=Km5 zpfHvy?$}7 zVt8Cqr*!C-2K!He8b*$61poj54YEK008kf_V*mgE9!_azXml=5W-e4{WiCuj zVQy}3b#7y22_9IyWT9T~w{1I&etdWAE@7ry9(NkRaqL^O*3VDXl-B4oniu@J1a6)9 zuAq!s)vU0ib(u<}pF9t8h+d}*hyVZp4-Wx27X%6c0RjN=1vD}=F*7kaG%`3fIWagi zH8eFaHW>gi02)jP0P_g|5p-p9bRcDJbaHthF*hfd~%#P&#=vdW}nqnip1lb`_(L;f=)%UM62w=zqnRHV6ptzD=Mo zmMZTg$36zh>x5gc=nSyfToVvfDvIP(XFViMBLYpNWH1RHSiEGRUhlVUJB)sOckC`< zrdu9&8o+VvTeH^BPt}yx=rfuZ{J8{fo%pVxj9S&Ku%mUEN~E7W4|0fJrwp1GnpiHX zlhz9;MN`n#EYfMub^~?v_7OId5f0@cnJ7fLr%Vt%tCBs<1qOu`$pm$8ggoAs_@|@s NR)p)cQw#tA007|NU5o$# literal 0 HcmV?d00001 diff --git a/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/893e876d3d09f0bb87bf95036b3d5e26/000000000009.mdblob.lz4 new file mode 100644 index 0000000000000000000000000000000000000000..d45aab1689853f014541cbf5a11c8c9a98b6cb73 GIT binary patch literal 223 zcmV<503iPaB25@TK)}!d008kf0W1Iji9UyIJqhr;hrg8rYdu~jUsmXU#g{e+2=Km5 zpfHvy?#y&jR4UGtKc`cEo~O za4lhNWHvM|X>)G?000U@Z*6dLWpi_7WB>pFCunqZa5^t9bZ>HUWo~pXKLGGUE@N+P zIyN~rIWJ*uZf|vNV`Y%lo?1hmGbMY~3Wjkq7{1^xfOumDs-8K4$V|t6j203^Ja~uv h>WG~H)Tx5H(4UWLo*>>jhD6$%T*(iPdI10c001CoZ`l9< diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000006.mdblob.lz4 deleted file mode 100644 index 5ed7e4cd80850511a6a571679016eb63aa66c703..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 539 zcmV+$0_6P!B25@TK)?n8008kf0003j&3%h!w&@J&`S}axTQAVjFDZ?WRd&e_h)r|L z7XD{H90p>#y&jR4UGtKc`cEo~$}7 zVt8Cqr*!C-2K!He8b*$61poj54YEK008kf_V*mgE9!_azXml=5W-e4{WiCuj zVQy}3b#7y2C}v=1QUT>KS=hz_@y1KUFtcJ6n}GpwJUp~f9)O|p(dzbfy?yw3-7kU! zd5RgAz0N|K1avM|abSsId?FGFhX4Qo4-Wx27X%6c0RjN=1vxk|FfuVWH8nXfIW#ac zH8?OaHW>gi00lw_0P_d{5Oig8bRcDJbaHthIW#jcIRF3v1$F@e@G<}{Vfj#yA9xBP zi57s*ZFQ_052ox2)V=jj9AN2~X3 zI?4if0000F-2gZf1+f4H0s!&_H843jG&wLhGB!0hHZ?doG%-0e9RMO6RR{nm01@2) zSu-&-F*Du(@G&Xuh~#pLQ79iAX^aH7s`nIv^e~2ElGX`3uYF~$QkYD!K1dYt!7=aw dKCt?l&P+;gtt}p537^!-eo$@@)e8Us003v3!e{^h diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000007.mdblob.lz4 deleted file mode 100644 index 7e7e99a3c2f5f8c4845c734a0919bf9587c26838..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 211 zcmV;^04)CmB25@TK)}QR0Du4h0rbs%i)Xg!4C?v$3+7ud(9tg`jgM7!$q$H4bITU~ zXFnVUV!FK^k(OQal9&2VDvIT3JP)NYs6pkmU(kxK@3tsrU}sVR_q4Lq{_I15|_<7wgf&_Vr8JE4zLYf40E>>}1iD7&q5(x@XyGnZ` zv$YaB#GV{HIqGmoeA49XwP+K#+Gc=Sxu6QgR8E|&k}v1{sQCAztNAZeDKRrpuW}7R NJZ0{Q0uBHG005!1VQc^Z diff --git a/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 b/Tribler/Test/Core/data/sample_channel/cd7d8b67b6e90ceaf9f90be65b2fd0d1/000000000009.mdblob.lz4 deleted file mode 100644 index 3857dbcf66cf1629f81d5e5567462695b0fe222d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 223 zcmV<503iPaB25@TK)}!d008kf0003j&3%h!w&@J&`S}axTQAVjFDZ?WRd&e_h)r|L z7XD{H90p>#y&jR4UGtKc`cEo~mNikr+4%jn-FNK;eJRWIRn0sy6{KI#vs{fpT zDUAt|umG|r*6Vt}go+H6&iemRaxZuollHdr>+9K5vfi6+w{&=S-ctX0l<043_tJT9 h6=rU2PPld9Yw#oH?C;JGx*FD(Z7+FV{rt)OEL#7Nz;G%Yjr)%u;E{7xWe)2*7yru>)vl;zz*15r%SAeaXp_W5WC>@_628c0(-NX zVM=2h*YW|`6YF&yJ~CYH6Ks0$`H0}bdu#92NmNO-z3RKUQ{Fp<&r4`||I8MR^X=EY gewC?fKD&|6yxN@q>gNXLxq`-zas+PncC6$E0GiuSf&c&j diff --git a/Tribler/Test/Core/data/sample_channel/channel.torrent b/Tribler/Test/Core/data/sample_channel/channel.torrent index 025cd9c434c..0e751ae3caf 100644 --- a/Tribler/Test/Core/data/sample_channel/channel.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel.torrent @@ -1 +1 @@ -d13:creation datei1547808014e4:infod5:filesld6:lengthi539e4:pathl23:000000000006.mdblob.lz4eed6:lengthi211e4:pathl23:000000000007.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:cd7d8b67b6e90ceaf9f90be65b2fd0d112:piece lengthi16384e6:pieces20:ÇŠIivçú‹Ox1v¶}øÕ9ee \ No newline at end of file +d13:creation datei1548884024e4:infod5:filesld6:lengthi538e4:pathl23:000000000006.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eed6:lengthi211e4:pathl23:000000000007.mdblob.lz4eee4:name32:893e876d3d09f0bb87bf95036b3d5e2612:piece lengthi16384e6:pieces20:Ç ™'·’ ÜkÙ·Í{ {Ïä75ee \ No newline at end of file diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob b/Tribler/Test/Core/data/sample_channel/channel_upd.mdblob index d5cfbfc5dad459b7e75d6d028d4a6cb1694cc643..5b252817503e977b572a6089ddd28f3e7242f11a 100644 GIT binary patch delta 208 zcmaFE_=T~aaRLKFr(JulE$4^b?fa)PXWPc9#fQD9KRVrvgX6mNikr+4mg+W*_YecUb*(spLL>Ke|!YR_UlfE zDUAt|umG|r*6Vt}gnr892=CrIv+fVu>MCcmv-fJ1&N9j5Rrc4itv7f&S(Q8P>WuW$ gRUARblr;`MtM+yG$SDht+<$?y|L>m!(T$hc0V$kGIsgCw delta 200 zcmeyu_=d5bfq`+t+1l>(Z7+FV{rt)OEL#7Nz;G%Yjr)%u;E{7xWe)2*7yru>)vl;zz#Ui2?=j4UGH&yg`vz0<%~w|?N4^d z!j#51uH^%=C)VpagcKZi4_UkP&V*AZFD#VF_;kK1{oiD@_;0%_n`KU=UD5u`G5<>R hpD71AcKd!=R@z#y^m_NZ=N1l0|FbPGEK#|~2>|6URObKy diff --git a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent index 768306932d5..b72fc4d5a25 100644 --- a/Tribler/Test/Core/data/sample_channel/channel_upd.torrent +++ b/Tribler/Test/Core/data/sample_channel/channel_upd.torrent @@ -1 +1 @@ -d13:creation datei1547808014e4:infod5:filesld6:lengthi539e4:pathl23:000000000006.mdblob.lz4eed6:lengthi223e4:pathl23:000000000009.mdblob.lz4eed6:lengthi211e4:pathl23:000000000007.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eee4:name32:cd7d8b67b6e90ceaf9f90be65b2fd0d112:piece lengthi16384e6:pieces20:(Ê`^—ƒCu‘!œkÏüžýd=Oee \ No newline at end of file +d13:creation datei1548884024e4:infod5:filesld6:lengthi538e4:pathl23:000000000006.mdblob.lz4eed6:lengthi283e4:pathl23:000000000002.mdblob.lz4eed6:lengthi223e4:pathl23:000000000009.mdblob.lz4eed6:lengthi211e4:pathl23:000000000007.mdblob.lz4eee4:name32:893e876d3d09f0bb87bf95036b3d5e2612:piece lengthi16384e6:pieces20:E™¨·SzN²I"ˆùÅÙ Íee \ No newline at end of file From 0ebf09530ccc4658981a902e3376af2d28a606fd Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Fri, 25 Jan 2019 16:25:18 +0100 Subject: [PATCH 21/38] Fixed searching/filtering with non-ascii character --- Tribler/Core/Modules/restapi/metadata_endpoint.py | 3 ++- Tribler/Core/Modules/restapi/search_endpoint.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 8ceec944345..7b30071ed0c 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -9,6 +9,7 @@ from twisted.web.server import NOT_DONE_YET from Tribler.pyipv8.ipv8.database import database_blob +from Tribler.util import cast_to_unicode_utf8 class BaseMetadataEndpoint(resource.Resource): @@ -22,7 +23,7 @@ def sanitize_parameters(parameters): last = 50 if 'last' not in parameters else int(parameters['last'][0]) sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) - query_filter = None if 'filter' not in parameters else parameters['filter'][0] + query_filter = None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]) if sort_by: sort_by = MetadataEndpoint.convert_sort_param_to_pony_col(sort_by) diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 4c82eb40b1f..db11e8a5521 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -109,7 +109,7 @@ def render_GET(self, request): return json.dumps({"error": "q parameter missing"}) first, last, sort_by, sort_asc, data_type = SearchEndpoint.sanitize_parameters(request.args) - query = request.args['q'][0] + query = cast_to_unicode_utf8(request.args['q'][0]) if not data_type: search_scope = lambda g: (g.metadata_type == REGULAR_TORRENT or g.metadata_type == CHANNEL_TORRENT) From 5d796526594f727dafe4fbe655f4991af3aa65f6 Mon Sep 17 00:00:00 2001 From: Martijn de Vos Date: Sun, 27 Jan 2019 16:32:43 +0100 Subject: [PATCH 22/38] Made a general URL encode mechanism This mechanism will encode POST data (encoded as application/x-www-form-urlencoded). In addition, I added a url_params parameter to the perform_request method, which takes (possibly unicode) characters and automatically constructs an URL with these parameters. post-rebase fix --- .../Core/APIImplementation/LaunchManyCore.py | 10 +---- Tribler/Core/Session.py | 1 - .../MetadataStore/test_channel_metadata.py | 6 ++- .../RestApi/test_downloads_endpoint.py | 2 +- TriblerGUI/dialogs/feedbackdialog.py | 25 ++++++------ TriblerGUI/tribler_request_manager.py | 34 +++++++++++++++- TriblerGUI/tribler_window.py | 21 +++++----- TriblerGUI/widgets/createtorrentpage.py | 22 ++++++----- .../widgets/downloadsdetailstabwidget.py | 6 +-- TriblerGUI/widgets/downloadspage.py | 10 ++--- TriblerGUI/widgets/editchannelpage.py | 39 +++++++++++-------- TriblerGUI/widgets/lazytableview.py | 4 +- TriblerGUI/widgets/marketpage.py | 8 +++- TriblerGUI/widgets/marketwalletspage.py | 2 +- TriblerGUI/widgets/settingspage.py | 2 +- TriblerGUI/widgets/subscriptionswidget.py | 6 +-- TriblerGUI/widgets/triblertablecontrollers.py | 17 ++++---- 17 files changed, 127 insertions(+), 88 deletions(-) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 1e26d47c77d..3fb6cd641b4 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -182,20 +182,12 @@ def load_ipv8_overlays(self): peer = Peer(self.session.trustchain_testnet_keypair) else: peer = Peer(self.session.trustchain_keypair) - - # Discovery Community - with open(self.session.config.get_permid_keypair_filename(), 'r') as key_file: - content = key_file.read() - content = content[31:-30].replace('\n', '').decode("BASE64") - peer = Peer(M2CryptoSK(keystring=content)) discovery_community = DualStackDiscoveryCommunity(peer, self.ipv8.endpoint, self.ipv8.network) discovery_community.resolve_dns_bootstrap_addresses() self.ipv8.overlays.append(discovery_community) self.ipv8.strategies.append((RandomChurn(discovery_community), -1)) self.ipv8.strategies.append((PeriodicSimilarity(discovery_community), -1)) - - if not self.session.config.get_dispersy_enabled(): - self.ipv8.strategies.append((RandomWalk(discovery_community), 20)) + self.ipv8.strategies.append((RandomWalk(discovery_community), 20)) # TrustChain Community if self.session.config.get_trustchain_enabled(): diff --git a/Tribler/Core/Session.py b/Tribler/Core/Session.py index beec42bb842..fab8d3f4d4f 100644 --- a/Tribler/Core/Session.py +++ b/Tribler/Core/Session.py @@ -19,7 +19,6 @@ import Tribler.Core.permid as permid_module from Tribler.Core.APIImplementation.LaunchManyCore import TriblerLaunchMany -from Tribler.Core.CacheDB.Notifier import Notifier from Tribler.Core.Config.tribler_config import TriblerConfig from Tribler.Core.Modules.restapi.rest_manager import RESTManager from Tribler.Core.Notifier import Notifier diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index d4e354ee086..a3c3f9bac0e 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -179,11 +179,13 @@ def test_add_metadata_to_channel(self): Test whether adding new torrents to a channel works as expected """ channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') - self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) + original_channel = channel_metadata.to_dict() + md = self.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) channel_metadata.commit_channel_torrent() self.assertEqual(channel_metadata.id_, ROOT_CHANNEL_ID) - self.assertEqual(channel_metadata.timestamp, 2) + self.assertLess(original_channel["timestamp"], channel_metadata.timestamp) + self.assertLess(md.timestamp, channel_metadata.timestamp) self.assertEqual(channel_metadata.num_entries, 1) @db_session diff --git a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py index e78578bf1da..ce50d7ad425 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py @@ -577,7 +577,7 @@ def verify_download(_): self.assertGreaterEqual(len(self.session.get_downloads()), 1) post_data = {'uri': 'file:%s' % os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')} - expected_json = {'started': True, 'infohash': '459718a85c45cf6e105da0ebb7a0cd3c518d6a83'} + expected_json = {'started': True, 'infohash': '6853d25535a1c7593e716dd6a69fc3dd7a7bfcc8'} return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, expected_json=expected_json).addCallback(verify_download) diff --git a/TriblerGUI/dialogs/feedbackdialog.py b/TriblerGUI/dialogs/feedbackdialog.py index 58c6bff1c4c..665b6a6b1c1 100644 --- a/TriblerGUI/dialogs/feedbackdialog.py +++ b/TriblerGUI/dialogs/feedbackdialog.py @@ -2,7 +2,6 @@ import json import os -from urllib import quote_plus from PyQt5 import uic from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QDialog, QTreeWidgetItem, QAction @@ -128,20 +127,24 @@ def on_send_clicked(self): sys_info = "" for ind in xrange(self.env_variables_list.topLevelItemCount()): item = self.env_variables_list.topLevelItem(ind) - sys_info += "%s\t%s\n" % (quote_plus(item.text(0)), quote_plus(item.text(1))) + sys_info += "%s\t%s\n" % (item.text(0), item.text(1)) comments = self.comments_text_edit.toPlainText() if len(comments) == 0: comments = "Not provided" - comments = quote_plus(comments) - - stack = quote_plus(self.error_text_edit.toPlainText()) - - post_data = "version=%s&machine=%s&os=%s×tamp=%s&sysinfo=%s&comments=%s&stack=%s" % \ - (self.tribler_version, platform.machine(), platform.platform(), - int(time.time()), sys_info, comments, stack) - - self.request_mgr.perform_request(endpoint, self.on_report_sent, data=str(post_data), method='POST') + stack = self.error_text_edit.toPlainText() + + post_data = { + "version": self.tribler_version, + "machine": platform.machine(), + "os": platform.platform(), + "timestamp": int(time.time()), + "sysinfo": sys_info, + "comments": comments, + "stack": stack + } + + self.request_mgr.perform_request(endpoint, self.on_report_sent, data=post_data, method='POST') def closeEvent(self, close_event): QApplication.quit() diff --git a/TriblerGUI/tribler_request_manager.py b/TriblerGUI/tribler_request_manager.py index 24f8c35764c..441300ff281 100644 --- a/TriblerGUI/tribler_request_manager.py +++ b/TriblerGUI/tribler_request_manager.py @@ -2,9 +2,11 @@ import logging from threading import RLock from time import time +from urllib import quote_plus from PyQt5.QtCore import QUrl, pyqtSignal, QIODevice, QBuffer, QObject from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest +from six import text_type import Tribler.Core.Utilities.json_util as json from TriblerGUI.defs import BUTTON_TYPE_NORMAL, DEFAULT_API_PORT, DEFAULT_API_PROTOCOL, DEFAULT_API_HOST @@ -234,21 +236,49 @@ def __init__(self, window=None): def set_reply_handle(self, reply): self.reply = reply - def perform_request(self, endpoint, read_callback, data="", method='GET', capture_errors=True, - priority=QueuePriorityEnum.CRITICAL, on_cancel=lambda: None): + def perform_request(self, endpoint, read_callback, url_params=None, data=None, raw_data="", method='GET', + capture_errors=True, priority=QueuePriorityEnum.CRITICAL, on_cancel=lambda: None): """ Perform a HTTP request. :param endpoint: the endpoint to call (i.e. "statistics") :param read_callback: the callback to be called with result info when we have the data + :param url_params: an optional dictionary with parameters that should be included in the URL :param data: optional POST data to be sent with the request + :param raw_data: optional raw data to include in the request, will get priority over data if defined :param method: the HTTP verb (GET/POST/PUT/PATCH) :param capture_errors: whether errors should be handled by this class (defaults to True) + :param priority: the priority of this request + :param on_cancel: optional callback to invoke when the request has been cancelled """ self.on_cancel = on_cancel if read_callback: self.received_json.connect(read_callback) + if url_params: + endpoint += "?" + '&'.join(["%s=%s" % (key, value) for key, value in url_params.items()]) + + if data and not raw_data: + # Convert all values that are an array to uri-encoded values + for key in data.keys(): + value = data[key] + if isinstance(value, list): + if value: + data[key + "[]"] = "&".join(value) + else: + del data[key] + + # Convert all keys and values in the data to utf-8 unicode strings + utf8_items = [] + for key, value in data.items(): + utf8_key = quote_plus(text_type(key).encode('utf-8')) + utf8_value = quote_plus(text_type(value).encode('utf-8')) + utf8_items.append("%s=%s" % (utf8_key, utf8_value)) + + data = "&".join(utf8_items) + elif raw_data: + data = raw_data.encode('utf-8') + def reply_callback(reply, log): log[-1] = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) self.on_finished(reply, capture_errors) diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index 314d588bcb6..8022f3a267e 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -33,7 +33,7 @@ from TriblerGUI.dialogs.startdownloaddialog import StartDownloadDialog from TriblerGUI.tribler_action_menu import TriblerActionMenu from TriblerGUI.tribler_request_manager import TriblerRequestManager, dispatcher, request_queue -from TriblerGUI.utilities import get_gui_setting, get_image_path, get_ui_file_path, is_dir_writable, quote_plus_unicode +from TriblerGUI.utilities import get_gui_setting, get_image_path, get_ui_file_path, is_dir_writable # Pre-load form UI classes fc_home_recommended_item, _ = uic.loadUiType(get_ui_file_path('home_recommended_item.ui')) @@ -385,18 +385,19 @@ def perform_start_download_request(self, uri, anon_download, safe_seeding, desti ConfirmationDialog.show_message(self.window(), "Download error %s" % uri, gui_error_message, "OK") return - selected_files_uri = "" + selected_files_list = [] if len(selected_files) != total_files: # Not all files included - selected_files_uri = u'&' + u''.join(u"selected_files[]=%s&" % - quote_plus_unicode(filename) for filename in selected_files)[:-1] + selected_files_list = [filename for filename in selected_files] anon_hops = int(self.tribler_settings['download_defaults']['number_hops']) if anon_download else 0 safe_seeding = 1 if safe_seeding else 0 - post_data = "uri=%s&anon_hops=%d&safe_seeding=%d&destination=%s%s" % (quote_plus_unicode(uri), anon_hops, - safe_seeding, destination, - selected_files_uri) - post_data = post_data.encode('utf-8') # We need to send bytes in the request, not unicode - + post_data = { + "uri": uri, + "anon_hops": anon_hops, + "safe_seeding": safe_seeding, + "destination": destination, + "selected_files": selected_files_list + } request_mgr = TriblerRequestManager() request_mgr.perform_request("downloads", callback if callback else self.on_download_added, method='PUT', data=post_data) @@ -441,7 +442,7 @@ def on_new_version_dialog_done(self, version, action): def on_search_text_change(self, text): self.search_suggestion_mgr = TriblerRequestManager() self.search_suggestion_mgr.perform_request( - "search/completions?q=%s" % text, self.on_received_search_completions) + "search/completions", self.on_received_search_completions, url_params={'q': text}) def on_received_search_completions(self, completions): if completions is None: diff --git a/TriblerGUI/widgets/createtorrentpage.py b/TriblerGUI/widgets/createtorrentpage.py index ebb7acbc0e5..451c3341525 100644 --- a/TriblerGUI/widgets/createtorrentpage.py +++ b/TriblerGUI/widgets/createtorrentpage.py @@ -1,7 +1,6 @@ from __future__ import absolute_import import os -import urllib from PyQt5.QtCore import QDir from PyQt5.QtGui import QIcon @@ -83,14 +82,18 @@ def on_create_clicked(self): self.window().edit_channel_create_torrent_button.setEnabled(False) - files_str = u"" + files_list = [] for ind in xrange(self.window().create_torrent_files_list.count()): - files_str += u"files[]=%s&" % urllib.quote_plus( - self.window().create_torrent_files_list.item(ind).text().encode('utf-8')) - - name = urllib.quote_plus(self.window().create_torrent_name_field.text().encode('utf-8')) - description = urllib.quote_plus(self.window().create_torrent_description_field.toPlainText().encode('utf-8')) - post_data = (u"%s&name=%s&description=%s" % (files_str[:-1], name, description)).encode('utf-8') + file_str = self.window().create_torrent_files_list.item(ind).text() + files_list.append(file_str) + + name = self.window().create_torrent_name_field.text() + description = self.window().create_torrent_description_field.toPlainText() + post_data = { + "name": name, + "description": description, + "files": files_list + } url = "createtorrent?download=1" if self.window().seed_after_adding_checkbox.isChecked() else "createtorrent" self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request(url, self.on_torrent_created, data=post_data, method='POST') @@ -109,10 +112,9 @@ def on_torrent_created(self, result): self.add_torrent_to_channel(result['torrent']) def add_torrent_to_channel(self, torrent): - post_data = str("torrent=%s" % urllib.quote_plus(torrent)) self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("mychannel/torrents", self.on_torrent_to_channel_added, - data=post_data, method='PUT') + data={"torrent": torrent}, method='PUT') def on_torrent_to_channel_added(self, result): if not result: diff --git a/TriblerGUI/widgets/downloadsdetailstabwidget.py b/TriblerGUI/widgets/downloadsdetailstabwidget.py index 4737c2a96dc..024e7430763 100644 --- a/TriblerGUI/widgets/downloadsdetailstabwidget.py +++ b/TriblerGUI/widgets/downloadsdetailstabwidget.py @@ -1,5 +1,3 @@ -from urllib import quote_plus - from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QTabWidget, QTreeWidgetItem, QAction @@ -225,7 +223,7 @@ def on_play_file(self, file_info): self.get_video_file_index(file_info["index"])) def set_included_files(self, files): - data_str = ''.join("selected_files[]=%s&" % ind for ind in files)[:-1] + post_data = {"selected_files": [ind for ind in files]} self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % self.current_download['infohash'], lambda _: None, - method='PATCH', data=data_str) + method='PATCH', data=post_data) diff --git a/TriblerGUI/widgets/downloadspage.py b/TriblerGUI/widgets/downloadspage.py index 2f9e2949177..057d9cb1e80 100644 --- a/TriblerGUI/widgets/downloadspage.py +++ b/TriblerGUI/widgets/downloadspage.py @@ -290,7 +290,7 @@ def on_start_download_clicked(self): infohash = selected_item.download_info["infohash"] self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % infohash, self.on_download_resumed, - method='PATCH', data="state=resume") + method='PATCH', data={"state": "resume"}) def on_download_resumed(self, json_result): if json_result and 'modified' in json_result: @@ -305,7 +305,7 @@ def on_stop_download_clicked(self): infohash = selected_item.download_info["infohash"] self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % infohash, self.on_download_stopped, - method='PATCH', data="state=stop") + method='PATCH', data={"state": "stop"}) def on_play_download_clicked(self): self.window().left_menu_button_video_player.click() @@ -341,7 +341,7 @@ def on_remove_download_dialog(self, action): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % infohash, self.on_download_removed, - method='DELETE', data="remove_data=%d" % action) + method='DELETE', data={"remove_data": action}) if self.dialog: self.dialog.close_dialog() self.dialog = None @@ -356,7 +356,7 @@ def on_force_recheck_download(self): infohash = selected_item.download_info["infohash"] self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % infohash, self.on_forced_recheck, - method='PATCH', data='state=recheck') + method='PATCH', data={"state": "recheck"}) def on_forced_recheck(self, result): if result and "modified" in result: @@ -371,7 +371,7 @@ def change_anonymity(self, hops): infohash = selected_item.download_info["infohash"] self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("downloads/%s" % infohash, lambda _: None, - method='PATCH', data='anon_hops=%d' % hops) + method='PATCH', data={"anon_hops": hops}) def on_explore_files(self): for selected_item in self.selected_items: diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index 4bd364870af..f549f6bfe1a 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -147,13 +147,13 @@ def on_create_channel_button_pressed(self): self.window().new_channel_name_label.setStyleSheet("color: red;") return - self.window().create_channel_button.setEnabled(False) + post_data = { + "name": channel_name, + "description": channel_description + } self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_created, - data=urllib.urlencode({u'name': channel_name.encode('utf-8'), - u'description': channel_description.encode( - 'utf-8')}), - method='PUT') + data=post_data, method='PUT') def on_channel_created(self, result): if not result: @@ -165,13 +165,14 @@ def on_channel_created(self, result): def on_edit_channel_save_button_pressed(self): channel_name = self.window().edit_channel_name_edit.text() channel_description = self.window().edit_channel_description_edit.toPlainText() + post_data = { + "name": channel_name, + "description": channel_description + } self.editchannel_request_mgr = TriblerRequestManager() self.editchannel_request_mgr.perform_request("mychannel", self.on_channel_edited, - data=urllib.urlencode({u'name': channel_name.encode('utf-8'), - u'description': channel_description.encode( - 'utf-8')}), - method='POST') + data=post_data, method='POST') def on_channel_edited(self, result): if not result: @@ -262,11 +263,15 @@ def on_torrents_remove_selected_action(self, action, items): items = [str(item) for item in items] infohashes = ",".join(items) + post_data = { + "infohashes": infohashes, + "status": COMMIT_STATUS_TODELETE + } + request_mgr = TriblerRequestManager() request_mgr.perform_request("mychannel/torrents", lambda response: self.on_torrents_removed_response(response, items), - data='infohashes=%s&status=%s' % (infohashes, COMMIT_STATUS_TODELETE), - method='POST') + data=post_data, method='POST') if self.dialog: self.dialog.close_dialog() self.dialog = None @@ -408,18 +413,20 @@ def on_channel_committed(self, result): def add_torrent_to_channel(self, filename): with open(filename, "rb") as torrent_file: - torrent_content = urllib.quote_plus(b64encode(torrent_file.read())) + torrent_content = b64encode(torrent_file.read()) request_mgr = TriblerRequestManager() request_mgr.perform_request("mychannel/torrents", self.on_torrent_to_channel_added, method='PUT', - data='torrent=%s' % torrent_content) + data={"torrent": torrent_content}) def add_dir_to_channel(self, dirname, recursive=False): + post_data = { + "torrents_dir": dirname, + "recursive": int(recursive) + } request_mgr = TriblerRequestManager() request_mgr.perform_request("mychannel/torrents", - self.on_torrent_to_channel_added, method='PUT', - data=((u'torrents_dir=%s' % dirname) + - (u'&recursive=1' if recursive else u'')).encode('utf-8')) + self.on_torrent_to_channel_added, method='PUT', data=post_data) def add_torrent_url_to_channel(self, url): request_mgr = TriblerRequestManager() diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 3a72392aaa1..2877024521d 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -89,7 +89,7 @@ def on_subscribe_control_clicked(self, index): request_mgr.perform_request("metadata/channels/%s" % public_key, lambda _: self.on_unsubscribed_channel.emit(index) if status else lambda _: self.on_subscribed_channel.emit(index), - data='subscribe=%i' % int(not status), method='POST') + data={"subscribe": int(not status)}, method='POST') index.model().data_items[index.row()][u'subscribed'] = int(not status) @@ -125,7 +125,7 @@ def on_commit_control_clicked(self, index): request_mgr = TriblerRequestManager() request_mgr.perform_request("mychannel/torrents/%s" % infohash, lambda response: self.on_torrent_status_updated(response, index), - data='status=%d' % new_status, method='PATCH') + data={"status": new_status}, method='PATCH') def on_torrent_status_updated(self, json_result, index): if not json_result: diff --git a/TriblerGUI/widgets/marketpage.py b/TriblerGUI/widgets/marketpage.py index 88dbd67bee8..153aa3d93ea 100644 --- a/TriblerGUI/widgets/marketpage.py +++ b/TriblerGUI/widgets/marketpage.py @@ -246,8 +246,12 @@ def create_order(self, is_ask, asset1_amount, asset1_type, asset2_amount, asset2 """ Create a new ask or bid order. """ - post_data = str("first_asset_amount=%d&first_asset_type=%s&second_asset_amount=%d&second_asset_type=%s" % - (asset1_amount, asset1_type, asset2_amount, asset2_type)) + post_data = { + "first_asset_amount": asset1_amount, + "first_asset_type": asset1_type, + "second_asset_amount": asset2_amount, + "second_asset_type": asset2_type + } self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("market/%s" % ('asks' if is_ask else 'bids'), lambda response: self.on_order_created(response, is_ask), diff --git a/TriblerGUI/widgets/marketwalletspage.py b/TriblerGUI/widgets/marketwalletspage.py index 886e995d766..2bcd63e282f 100644 --- a/TriblerGUI/widgets/marketwalletspage.py +++ b/TriblerGUI/widgets/marketwalletspage.py @@ -172,7 +172,7 @@ def should_create_wallet(self, wallet_id): return self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request("wallets/%s" % wallet_id, self.on_wallet_created, method='PUT', data='') + self.request_mgr.perform_request("wallets/%s" % wallet_id, self.on_wallet_created, method='PUT') def on_wallet_created(self, response): if not response: diff --git a/TriblerGUI/widgets/settingspage.py b/TriblerGUI/widgets/settingspage.py index a5118c4af40..35ad67347b8 100644 --- a/TriblerGUI/widgets/settingspage.py +++ b/TriblerGUI/widgets/settingspage.py @@ -449,7 +449,7 @@ def save_settings(self): self.settings_request_mgr = TriblerRequestManager() self.settings_request_mgr.perform_request("settings", self.on_settings_saved, - method='POST', data=json.dumps(settings_data)) + method='POST', raw_data=json.dumps(settings_data)) def on_settings_saved(self, data): if not data: diff --git a/TriblerGUI/widgets/subscriptionswidget.py b/TriblerGUI/widgets/subscriptionswidget.py index 81526ffa0cb..5292969db27 100644 --- a/TriblerGUI/widgets/subscriptionswidget.py +++ b/TriblerGUI/widgets/subscriptionswidget.py @@ -68,11 +68,11 @@ def on_subscribe_button_click(self): if int(self.channel_info["subscribed"]): self.request_mgr.perform_request("metadata/channels/%s" % self.channel_info['public_key'], - self.on_channel_unsubscribed, data='subscribe=0', method='POST') + self.on_channel_unsubscribed, data={"subscribe": 0}, method='POST') else: self.request_mgr.perform_request("metadata/channels/%s" % self.channel_info['public_key'], - self.on_channel_subscribed, data='subscribe=1', method='POST') + self.on_channel_subscribed, data={"subscribe": 1}, method='POST') def on_channel_unsubscribed(self, json_result): if not json_result: @@ -100,7 +100,7 @@ def on_credit_mining_button_click(self): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request("settings", self.on_credit_mining_sources, - method='PUT', data=json.dumps(settings)) + method='PUT', raw_data=json.dumps(settings)) def on_credit_mining_sources(self, json_result): if not json_result: diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index 125fdec170c..80f18465e03 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -80,15 +80,16 @@ def load_search_results(self, query, start=None, end=None): start, end = self.model.rowCount() + 1, self.model.rowCount() + self.model.item_load_batch sort_by, sort_asc = self._get_sort_parameters() + url_params = { + "q": query, + "first": start if start else '', + "last": end if end else '', + "sort_by": sort_by if sort_by else '', + "sort_asc": sort_asc, + "type": self.model.type_filter if self.model.type_filter else '' + } self.request_mgr = TriblerRequestManager() - self.request_mgr.perform_request( - ("search?q=%s" % query) - + (("&first=%i" % start) if start else '') - + (("&last=%i" % end) if end else '') - + (('&sort_by=%s' % sort_by) if sort_by else '') - + (('&sort_asc=%d' % sort_asc) if sort_asc else '') - + (('&type=%s' % self.model.type_filter) if self.model.type_filter else ''), - self.on_search_results) + self.request_mgr.perform_request("search", self.on_search_results, url_params=url_params) def on_search_results(self, response): if not response: From 3d822e7499a89b29eda333ab28458644e24eb183 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Fri, 1 Feb 2019 18:40:53 +0100 Subject: [PATCH 23/38] Proper FTS search sanitizing and UTF-8 --- .../Core/APIImplementation/LaunchManyCore.py | 1 - .../OrmBindings/channel_metadata.py | 4 +- .../MetadataStore/OrmBindings/channel_node.py | 2 +- .../OrmBindings/torrent_metadata.py | 9 +- Tribler/Core/Modules/gigachannel_manager.py | 73 +++++++--- .../Modules/restapi/mychannel_endpoint.py | 3 +- Tribler/Core/Session.py | 2 +- Tribler/Core/exceptions.py | 2 +- .../MetadataStore/test_channel_metadata.py | 4 +- .../MetadataStore/test_torrent_metadata.py | 7 +- .../Core/Modules/test_gigachannel_manager.py | 125 ++++++++++++++++++ TriblerGUI/tribler_request_manager.py | 53 ++++---- TriblerGUI/tribler_window.py | 4 +- TriblerGUI/widgets/editchannelpage.py | 2 +- TriblerGUI/widgets/triblertablecontrollers.py | 60 ++++++--- doc/restapi/introduction.rst | 2 +- 16 files changed, 266 insertions(+), 87 deletions(-) create mode 100644 Tribler/Test/Core/Modules/test_gigachannel_manager.py diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 3fb6cd641b4..442851c9edd 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -42,7 +42,6 @@ STATE_START_API_ENDPOINTS, STATE_START_CREDIT_MINING, STATE_START_LIBTORRENT, STATE_START_TORRENT_CHECKER, STATE_START_WATCH_FOLDER) from Tribler.pyipv8.ipv8.dht.provider import DHTCommunityProvider -from Tribler.pyipv8.ipv8.keyvault.private.m2crypto import M2CryptoSK from Tribler.pyipv8.ipv8.peer import Peer from Tribler.pyipv8.ipv8.peerdiscovery.churn import RandomChurn from Tribler.pyipv8.ipv8.peerdiscovery.community import DiscoveryCommunity, PeriodicSimilarity diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index fbf2eb8f13e..973296fe86a 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -13,7 +13,7 @@ LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, ChannelMetadataPayload from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url -from Tribler.Core.exceptions import DuplicateChannelNameError, DuplicateTorrentFileError +from Tribler.Core.exceptions import DuplicateChannelIdError, DuplicateTorrentFileError from Tribler.pyipv8.ipv8.database import database_blob CHANNEL_DIR_NAME_LENGTH = 32 # Its not 40 so it could be distinguished from infohash @@ -129,7 +129,7 @@ def create_channel(cls, title, description): :return: The channel metadata """ if ChannelMetadata.get_channel_with_id(cls._my_key.pub().key_to_bin()[10:]): - raise DuplicateChannelNameError() + raise DuplicateChannelIdError() my_channel = cls(id_=ROOT_CHANNEL_ID, public_key=database_blob(cls._my_key.pub().key_to_bin()[10:]), title=title, tags=description, subscribed=True) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index c5d81418d92..57615fad623 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -210,7 +210,7 @@ def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): # Filter the results on a keyword or some keywords if query_filter: - pony_query = cls.search_keyword(query_filter + "*", lim=1000) + pony_query = cls.search_keyword(query_filter, lim=1000) # Sort the query if sort_by: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 48c117fc328..c7e6c344bb7 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -5,6 +5,7 @@ from pony import orm from pony.orm import db_session, raw_sql +from six import text_type from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY, TODELETE from Tribler.Core.Modules.MetadataStore.serialization import TorrentMetadataPayload, REGULAR_TORRENT @@ -63,22 +64,18 @@ def search_keyword(cls, query, lim=100): # Sanitize FTS query if not query or query == "*": return [] - # FIXME: !!!! DO PROPER SQL SANITIZING !!!! - query = unicode(query) - query = query.translate({ord(u'"'): u"\"\"", ord(u"'"): u"\'\'"}) - query = "("+query+")" fts_ids = raw_sql( 'SELECT rowid FROM FtsIndex WHERE FtsIndex MATCH $query ORDER BY bm25(FtsIndex) LIMIT $lim') return cls.select(lambda g: g.rowid in fts_ids) @classmethod - def get_auto_complete_terms(cls, keyword, max_terms, limit=100): + def get_auto_complete_terms(cls, keyword, max_terms, limit=10): if not keyword: return [] with db_session: - result = cls.search_keyword(keyword + "*", lim=limit)[:] + result = cls.search_keyword("\"" +keyword + "\"*", lim=limit)[:] titles = [g.title.lower() for g in result] # Copy-pasted from the old DBHandler (almost) completely diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index 18fb7529e6b..0a8b61d3056 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -6,6 +6,7 @@ from twisted.internet.task import LoopingCall from Tribler.Core.DownloadConfig import DownloadStartupConfig +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import COMMITTED from Tribler.Core.TorrentDef import TorrentDefNoMetainfo, TorrentDef from Tribler.Core.simpledefs import DLSTATUS_SEEDING from Tribler.pyipv8.ipv8.taskmanager import TaskManager @@ -26,9 +27,11 @@ def start(self): The Metadata Store checks the database at regular intervals to see if new channels are available for preview or subscribed channels require updating. """ - queue_check_interval = 5.0 # seconds - self.register_task("Process channels download queue", - LoopingCall(self.check_channels_updates)).start(queue_check_interval) + self.updated_my_channel() # Just in case + + channels_check_interval = 5.0 # seconds + self.register_task("Process channels download queue and remove cruft", + LoopingCall(self.service_channels)).start(channels_check_interval) def shutdown(self): """ @@ -36,26 +39,44 @@ def shutdown(self): """ self.shutdown_task_manager() + def remove_cruft_channels(self): + with db_session: + channels, _ = self.session.lm.mds.ChannelMetadata.get_channels(last=10000, subscribed=True) + subscribed_infohashes = [c.infohash for c in list(channels)] + + cruft_list = [d for d in self.session.lm.get_channel_downloads() \ + if (d.infohash not in subscribed_infohashes)] + self.remove_channels_downloads(cruft_list) + + def service_channels(self): + try: + self.remove_cruft_channels() + except: + pass + try: + self.check_channels_updates() + except: + pass + + def check_channels_updates(self): """ Check whether there are channels that are updated. If so, download the new version of the channel. """ # FIXME: These naughty try-except-pass workarounds are necessary to keep the loop going in all circumstances - try: - with db_session: - channels_queue = list(self.session.lm.mds.ChannelMetadata.get_updated_channels()) - - for channel in channels_queue: - try: - if not self.session.has_download(str(channel.infohash)): - self._logger.info("Downloading new channel version %s ver %i->%i", - str(channel.public_key).encode("hex"), - channel.local_version, channel.timestamp) - self.download_channel(channel) - except: - pass - except: - pass + + with db_session: + channels_queue = list(self.session.lm.mds.ChannelMetadata.get_updated_channels()) + + for channel in channels_queue: + try: + if not self.session.has_download(str(channel.infohash)): + self._logger.info("Downloading new channel version %s ver %i->%i", + str(channel.public_key).encode("hex"), + channel.local_version, channel.timestamp) + self.download_channel(channel) + except: + pass def on_channel_download_finished(self, download, channel_id, finished_deferred=None): """ @@ -82,7 +103,10 @@ def remove_channel(self, channel): # Remove all stuff matching the channel dir name / public key / torrent title remove_list = [d for d in self.session.lm.get_channel_downloads() if d.tdef.get_name_utf8() == channel.dir_name] + self.remove_channels_downloads(remove_list) + def remove_channels_downloads(self, remove_list): + # FIXME: handle the case when a torrent for the old version of the channel overlaps the current version def _on_remove_failure(failure): self._logger.error("Error when removing the channel download: %s", failure) @@ -105,19 +129,24 @@ def download_channel(self, channel): tdef = TorrentDefNoMetainfo(infohash=str(channel.infohash), name=channel.dir_name) download = self.session.start_download_from_tdef(tdef, dcfg) channel_id = channel.public_key - #TODO: add errbacks here! + # TODO: add errbacks here! download.finished_callback = lambda dl: self.on_channel_download_finished(dl, channel_id, finished_deferred) if download.get_state().get_status() == DLSTATUS_SEEDING and not download.finished_callback_already_called: download.finished_callback_already_called = True download.finished_callback(download) return download, finished_deferred - def updated_my_channel(self, new_torrent_path): + def updated_my_channel(self): """ Notify the core that we updated our channel. - :param new_torrent_path: path to the new torrent file """ - tdef = TorrentDef.load(new_torrent_path) + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if my_channel and my_channel.status == COMMITTED and \ + not self.session.has_download(str(my_channel.infohash)): + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + else: + return + tdef = TorrentDef.load(torrent_path) dcfg = DownloadStartupConfig() dcfg.set_dest_dir(self.session.lm.mds.channels_dir) dcfg.set_channel_download(True) diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index 710793e3388..aec7d9ef5f1 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -328,7 +328,6 @@ def render_POST(self, request): return json.dumps({"error": "your channel has not been created"}) if my_channel.commit_channel_torrent(): - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - self.session.lm.gigachannel_manager.updated_my_channel(torrent_path) + self.session.lm.gigachannel_manager.updated_my_channel() return json.dumps({"success": True}) diff --git a/Tribler/Core/Session.py b/Tribler/Core/Session.py index fab8d3f4d4f..44514bb0b68 100644 --- a/Tribler/Core/Session.py +++ b/Tribler/Core/Session.py @@ -526,7 +526,7 @@ def create_channel(self, name, description, mode=u'closed'): :param description: description of the Channel :param mode: mode of the Channel ('open', 'semi-open', or 'closed') :return: a channel ID - :raises a DuplicateChannelNameError if name already exists + :raises a DuplicateChannelIdError if name already exists """ return self.lm.channel_manager.create_channel(name, description, mode) diff --git a/Tribler/Core/exceptions.py b/Tribler/Core/exceptions.py index c61462c3722..d0c4a024f75 100644 --- a/Tribler/Core/exceptions.py +++ b/Tribler/Core/exceptions.py @@ -47,7 +47,7 @@ class InvalidSignatureException(TriblerException): pass -class DuplicateChannelNameError(TriblerException): +class DuplicateChannelIdError(TriblerException): """ The Channel name already exists in the ChannelManager channel list, i.e., one of your own Channels with the same name already exists. diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index a3c3f9bac0e..ab4e22e1029 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -14,7 +14,7 @@ from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef -from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelNameError +from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelIdError from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.Test.common import TORRENT_UBUNTU_FILE from Tribler.pyipv8.ipv8.database import database_blob @@ -102,7 +102,7 @@ def test_create_channel(self): channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') self.assertTrue(channel_metadata) - self.assertRaises(DuplicateChannelNameError, + self.assertRaises(DuplicateChannelIdError, self.mds.ChannelMetadata.create_channel, 'test', 'test') @db_session diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index b5500404329..89a8bd8ec52 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -74,10 +74,6 @@ def test_search_keyword(self): dict(self.torrent_template, title=u"\'", tags="audio")) orm.flush() - # Ensure that the thing is able to process all types of quotes - self.mds.TorrentMetadata.search_keyword("\'")[:] - self.mds.TorrentMetadata.search_keyword("\"")[:] - # Search for torrents with the keyword 'foo', it should return one result results = self.mds.TorrentMetadata.search_keyword("foo")[:] self.assertEqual(len(results), 1) @@ -121,7 +117,7 @@ def test_wildcard_search(self): self.assertEqual(0, len(self.mds.TorrentMetadata.search_keyword("*")[:])) self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("foobl*")[:])) self.assertEqual(2, len(self.mds.TorrentMetadata.search_keyword("foo*")[:])) - self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("(\"12*\" + \"foobl*\")")[:])) + self.assertEqual(1, len(self.mds.TorrentMetadata.search_keyword("(\"12\"* AND \"foobl\"*)")[:])) @db_session def test_stemming_search(self): @@ -169,6 +165,7 @@ def test_get_autocomplete_terms_max(self): autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms("sheep", 2) self.assertEqual(len(autocomplete_terms), 2) + autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms(".", 2) @db_session def test_get_torrents(self): diff --git a/Tribler/Test/Core/Modules/test_gigachannel_manager.py b/Tribler/Test/Core/Modules/test_gigachannel_manager.py new file mode 100644 index 00000000000..e1a914db42d --- /dev/null +++ b/Tribler/Test/Core/Modules/test_gigachannel_manager.py @@ -0,0 +1,125 @@ +import os + +from pony.orm import db_session +from twisted.internet.defer import inlineCallbacks + +from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.Modules.gigachannel_manager import GigaChannelManager +from Tribler.Core.TorrentDef import TorrentDef +from Tribler.Test.Core.base_test import TriblerCoreTest, MockObject +from Tribler.Test.common import TORRENT_UBUNTU_FILE +from Tribler.pyipv8.ipv8.database import database_blob +from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto + + +class TestGigaChannelManager(TriblerCoreTest): + + @db_session + def generate_personal_channel(self): + chan = self.mock_session.lm.mds.ChannelMetadata.create_channel(title="my test chan", description="test") + my_dir = os.path.abspath(os.path.join(self.mock_session.lm.mds.channels_dir, chan.dir_name)) + tdef = TorrentDef.load(TORRENT_UBUNTU_FILE) + chan.add_torrent_to_channel(tdef, None) + return chan + + @inlineCallbacks + def setUp(self): + yield super(TestGigaChannelManager, self).setUp() + my_key = default_eccrypto.generate_key(u"curve25519") + self.mock_session = MockObject() + self.mock_session.lm = MockObject() + self.mock_session.lm.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, + my_key) + + self.chanman = GigaChannelManager(self.mock_session) + + @inlineCallbacks + def tearDown(self): + self.mock_session.lm.mds.shutdown() + yield super(TestGigaChannelManager, self).tearDown() + + @db_session + def test_update_my_channel(self): + chan = self.generate_personal_channel() + chan.commit_channel_torrent() + self.torrent_added = False + + def mock_add(a, b): + self.torrent_added = True + + self.mock_session.lm.add = mock_add + # self.mock_session.has_download = lambda x: x == str(chan.infohash) + + # Check add personal channel on startup + self.mock_session.has_download = lambda _: False + self.chanman.start() + self.chanman.check_channels_updates() + self.assertTrue(self.torrent_added) + self.chanman.shutdown() + + # Check skip already added personal channel + self.mock_session.has_download = lambda x: x == str(chan.infohash) + self.torrent_added = False + self.chanman.start() + self.chanman.check_channels_updates() + self.assertFalse(self.torrent_added) + self.chanman.shutdown() + + def test_check_channels_updates(self): + with db_session: + chan = self.generate_personal_channel() + chan.commit_channel_torrent() + chan.local_version -= 1 + chan2 = self.mock_session.lm.mds.ChannelMetadata(title="bla", public_key=database_blob(str(123)), + signature=database_blob(str(345)), skip_key_check=True, + timestamp=123, local_version=123, subscribed=True) + chan3 = self.mock_session.lm.mds.ChannelMetadata(title="bla", public_key=database_blob(str(124)), + signature=database_blob(str(346)), skip_key_check=True, + timestamp=123, local_version=122, subscribed=False) + self.mock_session.has_download = lambda _: False + self.torrent_added = 0 + + def mock_dl(a): + self.torrent_added += 1 + + self.chanman.download_channel = mock_dl + + self.chanman.check_channels_updates() + # download_channel should only fire once - for the original subscribed channel + self.assertEqual(1, self.torrent_added) + + def test_remove_cruft_channels(self): + with db_session: + chan = self.generate_personal_channel() + chan.commit_channel_torrent() + chan.local_version -= 1 + ih_chan2 = database_blob(str(123)) + chan2 = self.mock_session.lm.mds.ChannelMetadata(title="bla", infohash=ih_chan2, + public_key=database_blob(str(123)), + signature=database_blob(str(345)), skip_key_check=True, + timestamp=123, local_version=123, subscribed=True) + ih_chan3 = database_blob(str(124)) + chan3 = self.mock_session.lm.mds.ChannelMetadata(title="bla", infohash=ih_chan3, + public_key=database_blob(str(124)), + signature=database_blob(str(346)), skip_key_check=True, + timestamp=123, local_version=122, subscribed=False) + + mock_dl_list = [MockObject() for _ in range(4)] + mock_dl_list[0].infohash = chan.infohash + mock_dl_list[1].infohash = ih_chan2 + mock_dl_list[2].infohash = ih_chan3 + mock_dl_list[3].infohash = database_blob(str(333)) + + def mock_get_channel_downloads(): + return mock_dl_list + + self.remove_list = [] + + def mock_remove_channels_downloads(remove_list): + self.remove_list = remove_list + + self.chanman.remove_channels_downloads = mock_remove_channels_downloads + self.mock_session.lm.get_channel_downloads = mock_get_channel_downloads + self.chanman.remove_cruft_channels() + # We want to remove torrents for (a) deleted channels and (b) unsubscribed channels + self.assertListEqual(self.remove_list, [mock_dl_list[2], mock_dl_list[3]]) diff --git a/TriblerGUI/tribler_request_manager.py b/TriblerGUI/tribler_request_manager.py index 441300ff281..133f3eeccc1 100644 --- a/TriblerGUI/tribler_request_manager.py +++ b/TriblerGUI/tribler_request_manager.py @@ -1,5 +1,5 @@ -from collections import deque, namedtuple import logging +from collections import deque, namedtuple from threading import RLock from time import time from urllib import quote_plus @@ -13,6 +13,30 @@ from TriblerGUI.dialogs.confirmationdialog import ConfirmationDialog +def tribler_urlencode(data): + # Convert all values that are an array to uri-encoded values + for key in data.keys(): + value = data[key] + if isinstance(value, list): + if value: + data[key + "[]"] = "&".join(value) + else: + del data[key] + + # Convert all keys and values in the data to utf-8 unicode strings + utf8_items = [] + for key, value in data.items(): + utf8_key = quote_plus(text_type(key).encode('utf-8')) + # Convert bool values to ints + if isinstance(value, bool): + value = int(value) + utf8_value = quote_plus(text_type(value).encode('utf-8')) + utf8_items.append("%s=%s" % (utf8_key, utf8_value)) + + data = "&".join(utf8_items) + return data + + class QueuePriorityEnum(object): """ Enum for HTTP request priority. @@ -47,7 +71,7 @@ def __init__(self, max_outstanding=50, timeout=15): self.medium_queue = [] self.low_queue = [] - self.lock = RLock() # Don't allow asynchronous access to the queue + self.lock = RLock() # Don't allow asynchronous access to the queue def parse_queue(self): """ @@ -117,7 +141,7 @@ def enqueue(self, request_manager, method, endpoint, data, read_callback, captur self.high_queue.append(queue_item) else: # Get the last item of the queue - last_item = self.high_queue.pop(self.max_outstanding -1) + last_item = self.high_queue.pop(self.max_outstanding - 1) # Add the original queue_item to the front of the queue self.high_queue.insert(0, queue_item) # reduce the priority of last_item and try to put in medium queue @@ -255,27 +279,10 @@ def perform_request(self, endpoint, read_callback, url_params=None, data=None, r if read_callback: self.received_json.connect(read_callback) - if url_params: - endpoint += "?" + '&'.join(["%s=%s" % (key, value) for key, value in url_params.items()]) + url = endpoint + (("?" + tribler_urlencode(url_params)) if url_params else "") if data and not raw_data: - # Convert all values that are an array to uri-encoded values - for key in data.keys(): - value = data[key] - if isinstance(value, list): - if value: - data[key + "[]"] = "&".join(value) - else: - del data[key] - - # Convert all keys and values in the data to utf-8 unicode strings - utf8_items = [] - for key, value in data.items(): - utf8_key = quote_plus(text_type(key).encode('utf-8')) - utf8_value = quote_plus(text_type(value).encode('utf-8')) - utf8_items.append("%s=%s" % (utf8_key, utf8_value)) - - data = "&".join(utf8_items) + data = tribler_urlencode(data) elif raw_data: data = raw_data.encode('utf-8') @@ -283,7 +290,7 @@ def reply_callback(reply, log): log[-1] = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) self.on_finished(reply, capture_errors) - request_queue.enqueue(self, method, endpoint, data, reply_callback, priority) + request_queue.enqueue(self, method, url, data, reply_callback, priority) @staticmethod def get_message_from_error(error): diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index 8022f3a267e..b4c580b3582 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -36,6 +36,8 @@ from TriblerGUI.utilities import get_gui_setting, get_image_path, get_ui_file_path, is_dir_writable # Pre-load form UI classes +from TriblerGUI.widgets.triblertablecontrollers import to_fts_query, sanitize_for_fts + fc_home_recommended_item, _ = uic.loadUiType(get_ui_file_path('home_recommended_item.ui')) fc_loading_list_item, _ = uic.loadUiType(get_ui_file_path('loading_list_item.ui')) @@ -442,7 +444,7 @@ def on_new_version_dialog_done(self, version, action): def on_search_text_change(self, text): self.search_suggestion_mgr = TriblerRequestManager() self.search_suggestion_mgr.perform_request( - "search/completions", self.on_received_search_completions, url_params={'q': text}) + "search/completions", self.on_received_search_completions, url_params={'q': sanitize_for_fts(text) }) def on_received_search_completions(self, completions): if completions is None: diff --git a/TriblerGUI/widgets/editchannelpage.py b/TriblerGUI/widgets/editchannelpage.py index f549f6bfe1a..60c5fcb249d 100644 --- a/TriblerGUI/widgets/editchannelpage.py +++ b/TriblerGUI/widgets/editchannelpage.py @@ -384,7 +384,7 @@ def on_torrent_from_url_dialog_done(self, action): def autocommit_fired(self): def commit_channel(overview): try: - if overview['mychannel']['dirty']: + if overview and overview['mychannel']['dirty']: TriblerRequestManager().perform_request("mychannel/commit", lambda _: None, method='POST', capture_errors=False) except KeyError: diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index 80f18465e03..38e21369bed 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -4,9 +4,26 @@ """ from __future__ import absolute_import +from six import text_type + from TriblerGUI.tribler_request_manager import TriblerRequestManager +def sanitize_for_fts(text): + return text_type(text).translate({ord(u"\""): u"\"\"", ord(u"\'"): u"\'\'"}) + + +def to_fts_query(text): + if not text: + return "" + words = text.split(" ") + + # TODO: add support for quoted exact searches + query_list = [u'\"' + sanitize_for_fts(word) + u'\"*' for word in words] + + return " AND ".join(query_list) + + class TriblerTableViewController(object): """ Base controller for a table view that displays some data. @@ -81,7 +98,7 @@ def load_search_results(self, query, start=None, end=None): sort_by, sort_asc = self._get_sort_parameters() url_params = { - "q": query, + "q": to_fts_query(query), "first": start if start else '', "last": end if end else '', "sort_by": sort_by if sort_by else '', @@ -146,12 +163,15 @@ def load_channels(self, start=None, end=None): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request( - "metadata/channels?first=%i&last=%i" % (start, end) - + ('&sort_by=%s' % sort_by) - + ('&sort_asc=%d' % sort_asc) - + ('&filter=%s' % filter_text) - + ('&subscribed=%d' % int(self.model.subscribed)), - self.on_channels) + "metadata/channels", + self.on_channels, + url_params={ + "first": start, + "last": end, + "sort_by": sort_by, + "sort_asc": sort_asc, + "filter": to_fts_query(filter_text), + "subscribed": self.model.subscribed}) def on_channels(self, response): if not response: @@ -219,11 +239,14 @@ def load_torrents(self, start=None, end=None): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request( - "metadata/channels/%s/torrents?first=%i&last=%i" % (self.model.channel_pk, start, end) - + ('&sort_by=%s' % sort_by) - + ('&sort_asc=%d' % sort_asc) - + ('&filter=%s' % filter_text), - self.on_torrents) + "metadata/channels/%s/torrents?" % self.model.channel_pk, + self.on_torrents, + url_params={ + "first": start, + "last": end, + "sort_by": sort_by, + "sort_asc": sort_asc, + "filter": to_fts_query(filter_text)}) def on_torrents(self, response): if not response: @@ -260,12 +283,13 @@ def load_torrents(self, start=None, end=None): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request( - "mychannel/torrents?first=%i&last=%i" % (start, end) - + ('&sort_by=%s' % sort_by) - + ('&sort_asc=%d' % sort_asc) - + ('&filter=%s' % filter_text) - + ('&exclude_deleted=1' if self.model.exclude_deleted else ''), - self.on_torrents) + "mychannel/torrents", + self.on_torrents, + url_params={ + "sort_by": sort_by, + "sort_asc": sort_asc, + "filter": to_fts_query(filter_text), + "exclude_deleted": self.model.exclude_deleted}) def on_torrents(self, response): if super(MyTorrentsTableViewController, self).on_torrents(response): diff --git a/doc/restapi/introduction.rst b/doc/restapi/introduction.rst index ba489b35e7d..90fff5e0e89 100644 --- a/doc/restapi/introduction.rst +++ b/doc/restapi/introduction.rst @@ -40,7 +40,7 @@ If a valid request of a client caused a recoverable error the response will have { "error": { "handled": True, - "code": "DuplicateChannelNameError", + "code": "DuplicateChannelIdError", "message": "Channel name already exists: foo" } } From 29a3bfdd599b756b12c92f5df56ca63a9c3b2798 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Tue, 5 Feb 2019 18:48:08 +0100 Subject: [PATCH 24/38] GigaChannelManager channels cruft removal --- .../OrmBindings/channel_metadata.py | 1 + Tribler/Core/Modules/gigachannel_manager.py | 79 ++++++++++++++++--- .../MetadataStore/test_channel_metadata.py | 9 +++ .../RestApi/test_mychannel_endpoint.py | 2 +- .../Core/Modules/test_gigachannel_manager.py | 71 +++++++++++++---- TriblerGUI/widgets/lazytableview.py | 2 +- 6 files changed, 136 insertions(+), 28 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 973296fe86a..c233f401d49 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -214,6 +214,7 @@ def commit_channel_torrent(self, new_start_timestamp=None): else: if new_start_timestamp: update_dict['start_timestamp'] = new_start_timestamp + new_infohash = update_dict['infohash'] if self.infohash != update_dict['infohash'] else None self.update_metadata(update_dict) self.local_version = self.timestamp # Change status of committed metadata and clean up obsolete TODELETE entries diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index 0a8b61d3056..66bcf6d9caa 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -30,8 +30,14 @@ def start(self): self.updated_my_channel() # Just in case channels_check_interval = 5.0 # seconds - self.register_task("Process channels download queue and remove cruft", - LoopingCall(self.service_channels)).start(channels_check_interval) + lcall = LoopingCall(self.service_channels) + d = self.register_task("Process channels download queue and remove cruft", lcall).start(channels_check_interval) + + #def handle(f): + # print "errback" + # print "we got an exception: %s" % (f.getTraceback(),) + # f.trap(RuntimeError) + #d.addErrback(handle) def shutdown(self): """ @@ -40,12 +46,23 @@ def shutdown(self): self.shutdown_task_manager() def remove_cruft_channels(self): + """ + Assembles a list of obsolete channel torrents to be removed. + The list is formed from older versions of channels we are subscribed to and from channel torrents we are not + subscribed to (i.e. we recently unsubscribed from these). The unsubscribed channels are removed completely + with their contents, while in the case of older versions the files are left in place because the newer version + possibly uses them. + :return: list of tuples (download_to_remove=download, remove_files=Bool) + """ with db_session: channels, _ = self.session.lm.mds.ChannelMetadata.get_channels(last=10000, subscribed=True) - subscribed_infohashes = [c.infohash for c in list(channels)] + subscribed_infohashes = [bytes(c.infohash) for c in list(channels)] + dirnames = [c.dir_name for c in channels] - cruft_list = [d for d in self.session.lm.get_channel_downloads() \ - if (d.infohash not in subscribed_infohashes)] + # TODO: add some more advanced logic for removal of older channel versions + cruft_list = [(d, d.get_def().get_name_utf8() not in dirnames) \ + for d in self.session.lm.get_channel_downloads() \ + if (bytes(d.get_def().infohash) not in subscribed_infohashes)] self.remove_channels_downloads(cruft_list) def service_channels(self): @@ -58,7 +75,6 @@ def service_channels(self): except: pass - def check_channels_updates(self): """ Check whether there are channels that are updated. If so, download the new version of the channel. @@ -102,19 +118,60 @@ def remove_channel(self, channel): channel.local_version = 0 # Remove all stuff matching the channel dir name / public key / torrent title - remove_list = [d for d in self.session.lm.get_channel_downloads() if d.tdef.get_name_utf8() == channel.dir_name] + remove_list = [(d, True) for d in self.session.lm.get_channel_downloads() if d.tdef.get_name_utf8() == channel.dir_name] self.remove_channels_downloads(remove_list) - def remove_channels_downloads(self, remove_list): - # FIXME: handle the case when a torrent for the old version of the channel overlaps the current version + # TODO: finish this routine + # This thing should check if the files in the torrent we're going to delete are used in another torrent for + # a newer version of the same channel, and determine a safe sub-set to delete. + """ + def safe_files_to_remove(self, download): + # Check for intersection of files from old download with files from the newer version of the same channel + dirname = download.get_def().get_name_utf8() + files_to_remove = [] + with db_session: + channel = self.session.lm.mds.ChannelMetadata.get_channel_with_dirname(dirname) + if channel and channel.subscribed: + print self.session.lm.downloads + current_version = self.session.get_download(hexlify(channel.infohash)) + current_version_files = set(current_version.get_tdef().get_files()) + obsolete_version_files = set(download.get_tdef().get_files()) + files_to_remove_relative = obsolete_version_files - current_version_files + for f in files_to_remove_relative: + files_to_remove.append(os.path.join(dirname, f)) + return files_to_remove + """ + + def remove_channels_downloads(self, to_remove_list): + """ + :param to_remove_list: list of tuples (download_to_remove=download, remove_files=Bool) + """ + + """ + files_to_remove = [] + for download in to_remove_list: + files_to_remove.extend(self.safe_files_to_remove(download)) + """ + def _on_remove_failure(failure): self._logger.error("Error when removing the channel download: %s", failure) - for i, d in enumerate(remove_list): - deferred = self.session.remove_download(d, remove_content=True) + # removed_list = [] + for i, dl_tuple in enumerate(to_remove_list): + d, remove_content = dl_tuple + deferred = self.session.remove_download(d, remove_content=remove_content) deferred.addErrback(_on_remove_failure) self.register_task(u'remove_channel' + d.tdef.get_name_utf8() + u'-' + hexlify(d.tdef.get_infohash()) + u'-' + str(i), deferred) + # removed_list.append(deferred) + + """ + def _on_torrents_removed(torrent): + print files_to_remove + dl = DeferredList(removed_list) + dl.addCallback(_on_torrents_removed) + self.register_task(u'remove_channels_files-' + "_".join([d.tdef.get_name_utf8() for d in to_remove_list]), dl) + """ def download_channel(self, channel): """ diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index ab4e22e1029..a151c1f3c53 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -249,6 +249,15 @@ def test_delete_torrent_from_channel(self): channel_metadata.commit_channel_torrent() self.assertEqual(0, len(channel_metadata.contents_list)) + @db_session + def test_commit_channel_torrent(self): + channel = self.mds.ChannelMetadata.create_channel('test', 'test') + tdef = TorrentDef.load(TORRENT_UBUNTU_FILE) + channel.add_torrent_to_channel(tdef, None) + # The first run should return the infohash, the second should return None, because nothing was really done + self.assertTrue(channel.commit_channel_torrent()) + self.assertFalse(channel.commit_channel_torrent()) + @db_session def test_consolidate_channel_torrent(self): """ diff --git a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py index 1137e788f34..8a837566290 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py @@ -19,7 +19,7 @@ def setUp(self): yield super(BaseTestMyChannelEndpoint, self).setUp() self.session.lm.gigachannel_manager = MockObject() self.session.lm.gigachannel_manager.shutdown = lambda: None - self.session.lm.gigachannel_manager.updated_my_channel = lambda _: None + self.session.lm.gigachannel_manager.updated_my_channel = lambda: None def create_my_channel(self): with db_session: diff --git a/Tribler/Test/Core/Modules/test_gigachannel_manager.py b/Tribler/Test/Core/Modules/test_gigachannel_manager.py index e1a914db42d..5571a19a07b 100644 --- a/Tribler/Test/Core/Modules/test_gigachannel_manager.py +++ b/Tribler/Test/Core/Modules/test_gigachannel_manager.py @@ -1,8 +1,10 @@ import os +from datetime import datetime from pony.orm import db_session from twisted.internet.defer import inlineCallbacks +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.Modules.gigachannel_manager import GigaChannelManager from Tribler.Core.TorrentDef import TorrentDef @@ -25,6 +27,12 @@ def generate_personal_channel(self): @inlineCallbacks def setUp(self): yield super(TestGigaChannelManager, self).setUp() + self.torrent_template = { + "title": "", + "infohash": "", + "torrent_date": datetime(1970, 1, 1), + "tags": "video" + } my_key = default_eccrypto.generate_key(u"curve25519") self.mock_session = MockObject() self.mock_session.lm = MockObject() @@ -52,6 +60,7 @@ def mock_add(a, b): # Check add personal channel on startup self.mock_session.has_download = lambda _: False + self.chanman.service_channels = lambda: None # Disable looping call self.chanman.start() self.chanman.check_channels_updates() self.assertTrue(self.torrent_added) @@ -90,25 +99,52 @@ def mock_dl(a): def test_remove_cruft_channels(self): with db_session: - chan = self.generate_personal_channel() - chan.commit_channel_torrent() - chan.local_version -= 1 - ih_chan2 = database_blob(str(123)) - chan2 = self.mock_session.lm.mds.ChannelMetadata(title="bla", infohash=ih_chan2, + # Our personal chan is created, then updated, so there are 2 files on disk and there are 2 torrents: + # the old one and the new one + my_chan = self.generate_personal_channel() + my_chan.commit_channel_torrent() + my_chan_old_infohash = my_chan.infohash + md = self.mock_session.lm.mds.TorrentMetadata.from_dict(dict(self.torrent_template, status=NEW)) + my_chan.commit_channel_torrent() + + # Now we add external channel we are subscribed to. + chan2 = self.mock_session.lm.mds.ChannelMetadata(title="bla1", infohash=database_blob(str(123)), public_key=database_blob(str(123)), signature=database_blob(str(345)), skip_key_check=True, timestamp=123, local_version=123, subscribed=True) - ih_chan3 = database_blob(str(124)) - chan3 = self.mock_session.lm.mds.ChannelMetadata(title="bla", infohash=ih_chan3, + + # Another external channel, but there is a catch: we recently unsubscribed from it + chan3 = self.mock_session.lm.mds.ChannelMetadata(title="bla2", infohash=database_blob(str(124)), public_key=database_blob(str(124)), signature=database_blob(str(346)), skip_key_check=True, - timestamp=123, local_version=122, subscribed=False) - - mock_dl_list = [MockObject() for _ in range(4)] - mock_dl_list[0].infohash = chan.infohash - mock_dl_list[1].infohash = ih_chan2 - mock_dl_list[2].infohash = ih_chan3 - mock_dl_list[3].infohash = database_blob(str(333)) + timestamp=123, local_version=123, subscribed=False) + + class mock_dl(MockObject): + def __init__(self, infohash, dirname): + self.infohash = infohash + self.dirname = dirname + + def get_def(self): + a = MockObject() + a.infohash = self.infohash + a.get_name_utf8 = lambda: self.dirname + return a + + # Double conversion is required to make sure that buffers signatures are not the same + mock_dl_list = [ + # Downloads for our personal channel + mock_dl(database_blob(bytes(my_chan_old_infohash)), my_chan.dir_name), + mock_dl(database_blob(bytes(my_chan.infohash)), my_chan.dir_name), + + # Downloads for the updated external channel: "old ones" and "recent" + mock_dl(database_blob(bytes(str(12331244))), chan2.dir_name), + mock_dl(database_blob(bytes(chan2.infohash)), chan2.dir_name), + + # Downloads for the unsubscribed external channel + mock_dl(database_blob(bytes(str(1231551))), chan3.dir_name), + mock_dl(database_blob(bytes(chan3.infohash)), chan3.dir_name), + # Orphaned download + mock_dl(database_blob(str(333)), u"blabla")] def mock_get_channel_downloads(): return mock_dl_list @@ -122,4 +158,9 @@ def mock_remove_channels_downloads(remove_list): self.mock_session.lm.get_channel_downloads = mock_get_channel_downloads self.chanman.remove_cruft_channels() # We want to remove torrents for (a) deleted channels and (b) unsubscribed channels - self.assertListEqual(self.remove_list, [mock_dl_list[2], mock_dl_list[3]]) + self.assertItemsEqual(self.remove_list, + [(mock_dl_list[0], False), + (mock_dl_list[2], False), + (mock_dl_list[4], True), + (mock_dl_list[5], True), + (mock_dl_list[6], True)]) diff --git a/TriblerGUI/widgets/lazytableview.py b/TriblerGUI/widgets/lazytableview.py index 2877024521d..548c17e9ef1 100644 --- a/TriblerGUI/widgets/lazytableview.py +++ b/TriblerGUI/widgets/lazytableview.py @@ -143,7 +143,7 @@ def on_delete_button_clicked(self, index): request_mgr = TriblerRequestManager() request_mgr.perform_request("mychannel/torrents/%s" % index.model().data_items[index.row()][u'infohash'], lambda response: self.on_torrent_status_updated(response, index), - data='status=%d' % COMMIT_STATUS_TODELETE, method='PATCH') + data={"status" : COMMIT_STATUS_TODELETE}, method='PATCH') class SearchResultsTableView(ItemClickedMixin, DownloadButtonMixin, PlayButtonMixin, SubscribeButtonMixin, From b58948a5a56be03815cab9c80d648fdb079255e4 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 6 Feb 2019 15:55:05 +0100 Subject: [PATCH 25/38] redo category and family filters --- .../Core/APIImplementation/LaunchManyCore.py | 2 - Tribler/Core/Category/Category.py | 113 +++++------------- Tribler/Core/Category/FamilyFilter.py | 62 +++++----- Tribler/Core/Category/category.conf | 8 +- Tribler/Core/Category/filter_terms.filter | 93 +------------- Tribler/Core/Config/config.spec | 1 - Tribler/Core/Config/tribler_config.py | 7 -- .../OrmBindings/channel_metadata.py | 16 ++- .../OrmBindings/torrent_metadata.py | 10 +- Tribler/Core/Modules/MetadataStore/store.py | 3 - .../Core/Modules/restapi/metadata_endpoint.py | 21 ++-- .../Modules/restapi/mychannel_endpoint.py | 4 +- .../Core/Modules/restapi/search_endpoint.py | 22 ++-- Tribler/Core/Upgrade/db72_to_pony.py | 7 +- Tribler/Test/Core/Category/test_category.py | 26 +--- .../Test/Core/Category/test_family_filter.py | 17 ++- .../Test/Core/Config/test_tribler_config.py | 3 - .../Core/Modules/RestApi/base_api_test.py | 3 - .../Modules/RestApi/test_settings_endpoint.py | 6 +- TriblerGUI/qt_resources/mainwindow.ui | 3 +- TriblerGUI/tribler_window.py | 6 +- TriblerGUI/widgets/channelpage.py | 9 +- TriblerGUI/widgets/discoveredpage.py | 8 +- TriblerGUI/widgets/searchresultspage.py | 8 +- TriblerGUI/widgets/settingspage.py | 13 +- TriblerGUI/widgets/tablecontentmodel.py | 19 +-- TriblerGUI/widgets/triblertablecontrollers.py | 5 +- 27 files changed, 183 insertions(+), 312 deletions(-) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 442851c9edd..0a03d2cf4d2 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -21,7 +21,6 @@ from twisted.internet.threads import deferToThread from twisted.python.threadable import isInIOThread -from Tribler.Core.Category.Category import Category from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.Modules.gigachannel_manager import GigaChannelManager @@ -134,7 +133,6 @@ def register(self, session, session_lock): self.session = session self.session_lock = session_lock - self.category = Category() self.tracker_manager = TrackerManager(self.session) # On Mac, we bundle the root certificate for the SSL validation since Twisted is not using the root diff --git a/Tribler/Core/Category/Category.py b/Tribler/Core/Category/Category.py index 106f3ef2089..fd88a6c73c2 100644 --- a/Tribler/Core/Category/Category.py +++ b/Tribler/Core/Category/Category.py @@ -4,57 +4,54 @@ Author(s): Yuan Yuan, Jelle Roozenburg """ from __future__ import absolute_import, division -from functools import cmp_to_key + import logging import os import re -from six.moves.configparser import MissingSectionHeaderError, ParsingError +from functools import cmp_to_key -from Tribler.Core.Category.FamilyFilter import XXXFilter +from Tribler.Core.Category.FamilyFilter import default_xxx_filter from Tribler.Core.Category.init_category import getCategoryInfo from Tribler.Core.Utilities.install_dir import get_lib_path CATEGORY_CONFIG_FILE = "category.conf" -class Category(object): +def cmp_rank(a, b): + if not ('rank' in a): + return 1 + elif not ('rank' in b): + return -1 + elif a['rank'] == -1: + return 1 + elif b['rank'] == -1: + return -1 + elif a['rank'] == b['rank']: + return 0 + elif a['rank'] < b['rank']: + return -1 + else: + return 1 - __size_change = 1024 * 1024 - def __init__(self, ffEnabled=False): - self._logger = logging.getLogger(self.__class__.__name__) +class Category(object): + __size_change = 1024 * 1024 + _logger = logging.getLogger("Category") - filename = os.path.join(get_lib_path(), 'Core', 'Category', CATEGORY_CONFIG_FILE) - try: - self.category_info = getCategoryInfo(filename) - self.category_info.sort(key=cmp_to_key(cmp_rank)) - except (MissingSectionHeaderError, ParsingError, IOError): - self.category_info = [] - self._logger.critical('', exc_info=True) - - self.xxx_filter = XXXFilter() - - self._logger.debug("category: Categories defined by user: %s", self.getCategoryNames()) - - self.ffEnabled = ffEnabled - self.set_family_filter(None) - - def getCategoryNames(self, filter=True): - if self.category_info is None: - return [] - keys = [] - for category in self.category_info: - rank = category['rank'] - if rank == -1 and filter: - break - keys.append((category['name'], category['displayname'])) - return keys + category_info = getCategoryInfo(os.path.join(get_lib_path(), 'Core', 'Category', CATEGORY_CONFIG_FILE)) + category_info.sort(key=cmp_to_key(cmp_rank)) def calculateCategory(self, torrent_dict, display_name): """ Calculate the category for a given torrent_dict of a torrent file. :return a list of categories this torrent belongs to. """ + is_xxx = default_xxx_filter.isXXXTorrent( + files_list=torrent_dict['info']["files"] if "files" in torrent_dict['info'] else [], + torrent_name=torrent_dict['info'].get("name"), + tracker=torrent_dict['info'].get("announce")) + if is_xxx: + return "xxx" files_list = [] try: # the multi-files mode @@ -75,9 +72,6 @@ def calculateCategory(self, torrent_dict, display_name): return self.calculateCategoryNonDict(files_list, display_name, tracker, comment) def calculateCategoryNonDict(self, files_list, display_name, tracker, comment): - if self.xxx_filter.isXXXTorrent(files_list, display_name, tracker, comment): - return 'xxx' - torrent_category = None # filename_list ready strongest_cat = 0.0 @@ -160,51 +154,6 @@ def judge(self, category, files_list, display_name=''): def _getWords(self, string): return self.WORDS_REGEXP.findall(string) - def family_filter_enabled(self): - """ - Return is xxx filtering is enabled in this client - """ - return self.ffEnabled - - def set_family_filter(self, b=None): - assert b in (True, False, None) - old = self.family_filter_enabled() - if b != old or b is None: # update category data if initial call, or if state changes - if b is None: - b = old - - self.ffEnabled = b - - # change category data - for category in self.category_info: - if category['name'] == 'xxx': - if b: - category['old-rank'] = category['rank'] - category['rank'] = -1 - elif category['rank'] == -1: - category['rank'] = category['old-rank'] - break - - def get_family_filter_sql(self): - if self.family_filter_enabled(): - forbiddencats = [cat['name'] for cat in self.category_info if cat['rank'] == -1] - if forbiddencats: - return " and category not in (%s)" % ','.join(["'%s'" % cat for cat in forbiddencats]) - return '' - -def cmp_rank(a, b): - if not ('rank' in a): - return 1 - elif not ('rank' in b): - return -1 - elif a['rank'] == -1: - return 1 - elif b['rank'] == -1: - return -1 - elif a['rank'] == b['rank']: - return 0 - elif a['rank'] < b['rank']: - return -1 - else: - return 1 +# Category filter should be stateless +default_category_filter = Category() diff --git a/Tribler/Core/Category/FamilyFilter.py b/Tribler/Core/Category/FamilyFilter.py index c4b44df7c9d..94a07b2c0cf 100644 --- a/Tribler/Core/Category/FamilyFilter.py +++ b/Tribler/Core/Category/FamilyFilter.py @@ -8,41 +8,40 @@ import logging import os import re + from six.moves import xrange from Tribler.Core.Utilities.install_dir import get_lib_path WORDS_REGEXP = re.compile('[a-zA-Z0-9]+') +termfilename = os.path.join(get_lib_path(), 'Core', 'Category', 'filter_terms.filter') -class XXXFilter(object): - def __init__(self): - super(XXXFilter, self).__init__() - self._logger = logging.getLogger(self.__class__.__name__) +def initTerms(filename): + terms = set() + searchterms = set() - termfilename = os.path.join(get_lib_path(), 'Core', 'Category', 'filter_terms.filter') - self.xxx_terms, self.xxx_searchterms = self.initTerms(termfilename) + try: + f = open(filename, 'r') + lines = f.read().lower().splitlines() - def initTerms(self, filename): - terms = set() - searchterms = set() + for line in lines: + if line.startswith('*'): + searchterms.add(line[1:]) + else: + terms.add(line) + f.close() + except IOError: + raise IOError(u"Could not open %s, initTerms failed.", filename) - try: - f = open(filename, 'r') - lines = f.read().lower().splitlines() + return terms, searchterms - for line in lines: - if line.startswith('*'): - searchterms.add(line[1:]) - else: - terms.add(line) - f.close() - except IOError: - self._logger.exception(u"Could not open %s, initTerms failed.", filename) - self._logger.debug('Read %d XXX terms from file %s', len(terms) + len(searchterms), filename) - return terms, searchterms +class XXXFilter(object): + _logger = logging.getLogger("XXXFilter") + + xxx_terms, xxx_searchterms = initTerms(termfilename) def _getWords(self, string): return [a.lower() for a in WORDS_REGEXP.findall(string)] @@ -52,12 +51,11 @@ def isXXXTorrent(self, files_list, torrent_name, tracker, comment=None): tracker = tracker.lower().replace('http://', '').replace('announce', '') else: tracker = '' - terms = [a[0].lower() for a in files_list] + terms = [a["path"][0] for a in files_list] if files_list else [] is_xxx = (self.isXXX(torrent_name, False) or self.isXXX(tracker, False) or any(self.isXXX(term) for term in terms) or - (comment and self.isXXX(comment, False)) - ) + (comment and self.isXXX(comment, False))) tracker = repr(tracker) if is_xxx: self._logger.debug(u"Torrent is XXX: %s %s", torrent_name, tracker) @@ -65,7 +63,13 @@ def isXXXTorrent(self, files_list, torrent_name, tracker, comment=None): self._logger.debug(u"Torrent is NOT XXX: %s %s", torrent_name, tracker) return is_xxx - def isXXX(self, s, isFilename=True): + def isXXXTorrentMetadataDict(self, md_dict): + terms_combined = " ".join([md_dict[f] for f in ["title", "tags", "tracker"] if f in md_dict]) + non_xxx = "tags" in md_dict and \ + (md_dict["tags"].startswith(u"audio") or md_dict["tags"].startswith(u"CD/DVD/BD")) + return self.isXXX(terms_combined, nonXXXFormat=non_xxx) + + def isXXX(self, s, isFilename=True, nonXXXFormat=False): if not s: return False @@ -77,7 +81,7 @@ def isXXX(self, s, isFilename=True): words = self._getWords(s) words2 = [' '.join(words[i:i + 2]) for i in xrange(0, len(words) - 1)] num_xxx = len([w for w in words + words2 if self.isXXXTerm(w, s)]) - if isFilename and self.isAudio(s): + if nonXXXFormat or (isFilename and self.isAudio(s)): return num_xxx > 2 # almost never classify mp3 as porn else: return num_xxx > 0 @@ -110,3 +114,7 @@ def isXXXTerm(self, s, title=None): def isAudio(self, s): return s[s.rfind('.') + 1:] in self.audio_extensions + + +# XXX filter should be stateless +default_xxx_filter = XXXFilter() diff --git a/Tribler/Core/Category/category.conf b/Tribler/Core/Category/category.conf index cec1a0bb84a..ec1bb71508d 100644 --- a/Tribler/Core/Category/category.conf +++ b/Tribler/Core/Category/category.conf @@ -53,7 +53,7 @@ matchpercentage = 0.8 [Compressed] rank = 4 displayname = Compressed -suffix = ace, bin, bwt, cab, ccd, cdi, cue, gzip, iso, jar, mdf, mds, nrg, rar, tar, vcd, z, zip +suffix = ace, bin, bwt, cab, gzip, jar, rar, tar, z, zip matchpercentage = 0.8 *.r0 = 1 @@ -67,6 +67,12 @@ matchpercentage = 0.8 *.r8 = 1 *.r9 = 1 +[CD/DVD/BD] +rank = 6 +displayname = Compressed +suffix = iso, mdf, mds, nrg, vcd, dmg, toast, cue, dvd, imd, md0, md1, md2, mdx, udf, wii, dmgpart, cso, ccd, cdi, dax, xvd, sub, daa, vc4, lcd +matchpercentage = 0.8 + [Picture] rank = 6 displayname = Pictures diff --git a/Tribler/Core/Category/filter_terms.filter b/Tribler/Core/Category/filter_terms.filter index 607e29734f3..7a9b4b551a8 100644 --- a/Tribler/Core/Category/filter_terms.filter +++ b/Tribler/Core/Category/filter_terms.filter @@ -33,7 +33,6 @@ abspritzen2 abspritzer abuse accidental -action adel miller adriana adrianna @@ -47,17 +46,15 @@ adults adultsex adulttv advertenties -afro afscheiding aftrekken agustina akiba -akira +asa akira alba albanian alektra alektra blue -alex alex divine alexa alexandra @@ -78,7 +75,6 @@ alsscan alysha leigh alyssa alyssa chase -amanda amanda dawkins amanda white amanda3 @@ -131,7 +127,6 @@ amor amore ampland amputee -amy anaal anaallikken anaalneuken @@ -153,14 +148,12 @@ analneuken analnow5 analpassion analthe -anderson andrea andrea spinks anetta keys anette angel eyes angel long -angela angelica angelica sin angelina @@ -169,20 +162,16 @@ angewichst angewixt angie angie george -angus anika animalporno animalsex animalsexcom animalsexverhalen anime -anita anita crystal anja anjali -anna anna malle -anne anneke annika antistress @@ -200,12 +189,8 @@ arab arabian archieven archiv -argentina -argentine -argentino ariana ariana jollee -army arsch arschbesamung arschdildo @@ -282,9 +267,6 @@ bakire2002 balkon balls balmoral -banana -banane -bananen bang bangbros bangbross @@ -319,12 +301,9 @@ bdsmstartpagina bdsmverhalen bdsmvideosnet bdsmzaken -beach beachgirls beastiality beavis -become -bed bedroom bedtime beefy @@ -338,7 +317,6 @@ bekijksex bekommt belgian belgischeporno -bella bella starr belladonna bellydance @@ -352,7 +330,6 @@ bestialiteit bestiality besuch_beim_na bethroom -betty bev cox bianca bianca black @@ -454,9 +431,7 @@ blown blowputa blows bobbi eden -bobbie bocca -body bodypanty bodystocking boerensex @@ -485,19 +460,11 @@ borstenforum borstjes botergeil botergeile -boxing -boyfriend -boyfriends boyfuckmom boyz bra brandi brandibelle -brasil -brasilian -brazil -braziliaanse -brazilian brazzers breast breasted @@ -505,7 +472,6 @@ breasts breezahchicks breezersletjes briana banks -bridget brigitte brinquedinho britney spears @@ -520,7 +486,6 @@ brunettes brutal brutaldildos brutalviolence -bubble bud buitenbloot buitensex @@ -530,7 +495,6 @@ bukake bukkake bukkakeshop bukkakeshopcom -bunny bunnyteens busen bust @@ -563,7 +527,6 @@ camcrush cameltoe cameltoes cameras -cameron camgirl camgirls camgirlyoung @@ -581,7 +544,6 @@ camsletten camwithher canaal canaaldigitaal -canal canaldigitaal canaldigital canalplus @@ -589,12 +551,10 @@ candi candice candice paris candid -candy carly carmel carmen electra caroline -carrot carsex cartoonsex carupaneras @@ -621,13 +581,11 @@ chandigarhdicke changingroom chantal chantelle stevens -charisma charlestonkleding charlie charlie holays charlie laine charlotte -charming chatbabe chathonk chatrooms @@ -638,7 +596,6 @@ cheating cheatingnice cheerleader chenfick -cherry chica chicca chick @@ -720,9 +677,7 @@ convulsions coolios copines coppia -coral cornelia -corrida corset cosplay couch @@ -799,7 +754,6 @@ cunny cunt cuntlicker cursus -cute cutie cuties cuty @@ -829,7 +783,6 @@ debbie tomlins debora deborah deborandome -deep deepest deepthroat deepthroatblowjob @@ -843,7 +796,6 @@ desire despues destiny deville destiny st claire -deutsche devasso devinn lane devon @@ -913,8 +865,6 @@ dogsex doityourself dolitha dolithas -doll -dolls domai domina dominica leoni @@ -1035,7 +985,6 @@ euromillions eutube eva angelina eva vortex -eve everysexhasits evgeniya executies @@ -1046,7 +995,6 @@ exhibitionist exibicionista exotic exotische -expert extreem extreemsex extrem @@ -1065,7 +1013,6 @@ facialsusana facialthreesome familie familiesex -famous famouspornstars fanny fart @@ -1163,7 +1110,6 @@ floral flower tucci follada follando -foot footjob footjob1 footjobbrazilian @@ -1184,7 +1130,6 @@ fran lord francaise francesa francine -frankfurt frankie fraportgrenzsc frauen @@ -1344,14 +1289,12 @@ gigolo gijl gilly sampson giovanni -girl girlblowjob girlfreind girlfriend girlfriends girlfucking girlmilking -girls girlsanal girlslikegirls girlsprive @@ -1424,7 +1367,6 @@ gratistrailers gratisverhalen gratiswebcamsex gratisxxx -greece greekvsenglish grieksesex grilfriend @@ -1480,7 +1422,6 @@ hardeporno hardeseks harige harigepoes -harmony harmony hex hart hathaway @@ -1503,7 +1444,6 @@ hella hentai hermano hermaphrodite -herself hete heterosexverhalen hetesletjes @@ -1531,8 +1471,6 @@ hoertje hoertjes hoes hogtied -hole -holes hollandse hollandsesex homefuck @@ -1562,7 +1500,6 @@ homoverhalen hondenneuken hondentrimmen hondenzaad -honey honeymoon honeysuckle hooker @@ -1622,19 +1559,15 @@ hustlermagazine hustlertv ideepthroat ideepthroatcom -idols inari vachs incest incestporno incestsex inclusive incubus -indian -indonesian industrion ingetrokken ingoio -innocent inserted insertinons insertion @@ -1877,7 +1810,6 @@ lass lasses latex latexsex -latina latincouple latino laura @@ -1959,7 +1891,6 @@ linh linsey dawn mckenzie lips lipstick -lisa lisa daniels lisa marie literotica @@ -2001,7 +1932,6 @@ lolly badcock lombardi londonamateur long dong silver -lords lorna lace lory lotion @@ -2012,7 +1942,6 @@ lube lubricando lucia luciana -lucky lucy gresty lucy law lucygirl @@ -2029,11 +1958,9 @@ lutschen lutscht luxi lyndsey love -lynn lynn stone lynsey madame sindi -mafia maid maids maidstone @@ -2136,11 +2063,9 @@ mellons mercedez merel messy -mexican mexicana mexicano mia stone -michelle michelle b michelle thorne microbikini @@ -2166,14 +2091,12 @@ miranda miriams mirjam mirjams -miss missbunny missionary missy mistreated mistres mistress -misty miyah mmmmm moby @@ -2189,7 +2112,6 @@ molige moms mondneuken moni -monica monica sweetheart monika monique @@ -2219,7 +2141,6 @@ mundfick muschie muschifingern mya diamond -myself mysexgames mystique naakt @@ -2261,8 +2182,6 @@ nattekutten nattepoes nattepoesjes nattespleet -natural -naturals naturewonderwoman naturisme naturist @@ -2323,7 +2242,6 @@ nightfly nightfuck1 nightie niki blond -nikita nikita devine nikki nikki hunter @@ -2523,7 +2441,6 @@ pinay pinklips pinkpornstars pinky -pipe pis piskut pisshunters @@ -2571,11 +2488,9 @@ poesjes poland polandbathroom polderrape -polish pompino pompoarism ponygirl -pool poolbest poolhappy poolside @@ -2763,7 +2678,6 @@ reinundraus reiten reiter reitet -renaissance renee pornero renee richards rica @@ -2828,18 +2742,15 @@ samantha sambal sammi jayne sandie caine -sandra sandra romain sandra russo sandramodel sandwich -sandy sapphic sapphicerotica sapphire raw sara nice sara stone -sarah sarah young sarahs sasha @@ -2902,7 +2813,6 @@ searchbigtits secretary seduction seductions -seeker sekfilms sekret seks @@ -3130,7 +3040,6 @@ showering showershaved showing shows -shy shyla siffredi silicon diff --git a/Tribler/Core/Config/config.spec b/Tribler/Core/Config/config.spec index 967f47b82ac..24768a77137 100644 --- a/Tribler/Core/Config/config.spec +++ b/Tribler/Core/Config/config.spec @@ -1,5 +1,4 @@ [general] -family_filter = boolean(default=True) state_dir = string(default='') ec_keypair_filename = string(default='') log_dir = string(default=None) diff --git a/Tribler/Core/Config/tribler_config.py b/Tribler/Core/Config/tribler_config.py index 1b29d4be672..77d31064595 100644 --- a/Tribler/Core/Config/tribler_config.py +++ b/Tribler/Core/Config/tribler_config.py @@ -138,13 +138,6 @@ def get_chant_channels_dir(self): path = self.config['chant']['channels_dir'] return path if os.path.isabs(path) else os.path.join(self.get_state_dir(), path) - # General - def set_family_filter_enabled(self, value): - self.config['general']['family_filter'] = bool(value) - - def get_family_filter_enabled(self): - return self.config['general'].as_bool('family_filter') - def set_state_dir(self, state_dir): self.config["general"]["state_dir"] = state_dir diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index c233f401d49..a236faa1cd1 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -9,6 +9,7 @@ from pony import orm from pony.orm import db_session, raw_sql, select +from Tribler.Core.Category.Category import default_category_filter from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import COMMITTED, NEW, PUBLIC_KEY_LEN, TODELETE, \ LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, ChannelMetadataPayload @@ -249,15 +250,14 @@ def add_torrent_to_channel(self, tdef, extra_info): """ if extra_info: tags = extra_info.get('description', '') - elif self._category_filter: - tags = self._category_filter.calculateCategory(tdef.metainfo, tdef.get_name_as_unicode()) else: - tags = '' + # We only want to determine the type of the data. XXX filtering is done by the receiving side + tags = default_category_filter.calculateCategory(tdef.metainfo, tdef.get_name_as_unicode()) new_entry_dict = { "infohash": tdef.get_infohash(), - "title": tdef.get_name_as_unicode(), - "tags": tags, + "title": tdef.get_name_as_unicode()[:300], # TODO: do proper size checking based on bytes + "tags": tags[:200], # TODO: do proper size checking based on bytes "size": tdef.get_length(), "torrent_date": datetime.fromtimestamp(tdef.get_creation_date()), "tracker_info": get_uniformed_tracker_url(tdef.get_tracker() or '') or '', @@ -433,19 +433,23 @@ def get_updated_channels(cls): @classmethod @db_session - def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, subscribed=False): + def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, subscribed=False, + hide_xxx=False): """ Get some channels. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ + # TODO: rewrite this with **kwargs expansion pony_query = ChannelMetadata.get_entries_query(sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) # Filter subscribed/non-subscribed if subscribed: pony_query = pony_query.where(subscribed=subscribed) + if hide_xxx: + pony_query = pony_query.where(lambda g: g.xxx == 0) total_results = pony_query.count() diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index c7e6c344bb7..af9da55ba56 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -5,8 +5,8 @@ from pony import orm from pony.orm import db_session, raw_sql -from six import text_type +from Tribler.Core.Category.FamilyFilter import default_xxx_filter from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY, TODELETE from Tribler.Core.Modules.MetadataStore.serialization import TorrentMetadataPayload, REGULAR_TORRENT from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url @@ -35,6 +35,8 @@ def __init__(self, *args, **kwargs): if "health" not in kwargs and "infohash" in kwargs: kwargs["health"] = db.TorrentState.get(infohash=kwargs["infohash"]) or db.TorrentState( infohash=kwargs["infohash"]) + if 'xxx' not in kwargs: + kwargs["xxx"] = default_xxx_filter.isXXXTorrentMetadataDict(kwargs) super(TorrentMetadata, self).__init__(*args, **kwargs) @@ -75,7 +77,7 @@ def get_auto_complete_terms(cls, keyword, max_terms, limit=10): return [] with db_session: - result = cls.search_keyword("\"" +keyword + "\"*", lim=limit)[:] + result = cls.search_keyword("\"" + keyword + "\"*", lim=limit)[:] titles = [g.title.lower() for g in result] # Copy-pasted from the old DBHandler (almost) completely @@ -102,7 +104,7 @@ def get_random_torrents(cls, limit): @classmethod @db_session def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, channel_pk=False, - exclude_deleted=False): + exclude_deleted=False, hide_xxx=False): """ Get some torrents. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. @@ -116,6 +118,8 @@ def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) if exclude_deleted: pony_query = pony_query.where(lambda g: g.status != TODELETE) + if hide_xxx: + pony_query = pony_query.where(lambda g: g.xxx == 0) # Filter on channel if channel_pk: diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index ff962678c0b..49a732e4f2f 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -111,9 +111,6 @@ def __init__(self, db_filename, channels_dir, my_key): self.ChannelMetadata._channels_dir = channels_dir - # TODO: move Category Filter into a module-level global stateless object (i.e. make it a singleton) - self.ChannelMetadata._category_filter = Category() - self._db.bind(provider='sqlite', filename=db_filename, create_db=create_db) if create_db: with db_session: diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 7b30071ed0c..5adc2a98bba 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -24,11 +24,13 @@ def sanitize_parameters(parameters): sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) query_filter = None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]) + hide_xxx = False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0) if sort_by: sort_by = MetadataEndpoint.convert_sort_param_to_pony_col(sort_by) - return first, last, sort_by, sort_asc, query_filter + # TODO: stop using tuples, do proper dict + return first, last, sort_by, sort_asc, query_filter, hide_xxx class MetadataEndpoint(BaseMetadataEndpoint): @@ -78,13 +80,13 @@ def sanitize_parameters(parameters): """ Sanitize the parameters for a request that fetches channels. """ - first, last, sort_by, sort_asc, query_filter = BaseMetadataEndpoint.sanitize_parameters(parameters) + first, last, sort_by, sort_asc, query_filter, hide_xxx = BaseMetadataEndpoint.sanitize_parameters(parameters) subscribed = False if 'subscribed' in parameters: subscribed = bool(int(parameters['subscribed'][0])) - return first, last, sort_by, sort_asc, query_filter, subscribed + return first, last, sort_by, sort_asc, query_filter, hide_xxx, subscribed class ChannelsEndpoint(BaseChannelsEndpoint): @@ -96,9 +98,10 @@ def getChild(self, path, request): return SpecificChannelEndpoint(self.session, path) def render_GET(self, request): - first, last, sort_by, sort_asc, query_filter, subscribed = ChannelsEndpoint.sanitize_parameters(request.args) + first, last, sort_by, sort_asc, query_filter, hide_xxx, subscribed = ChannelsEndpoint.sanitize_parameters( + request.args) channels, total = self.session.lm.mds.ChannelMetadata.get_channels( - first, last, sort_by, sort_asc, query_filter, subscribed) + first, last, sort_by, sort_asc, query_filter, subscribed, hide_xxx=hide_xxx) channels = [channel.to_simple_dict() for channel in channels] @@ -165,13 +168,13 @@ def sanitize_parameters(parameters): """ Sanitize the parameters for a request that fetches channels. """ - first, last, sort_by, sort_asc, query_filter = BaseMetadataEndpoint.sanitize_parameters(parameters) + first, last, sort_by, sort_asc, query_filter, hide_xxx = BaseMetadataEndpoint.sanitize_parameters(parameters) channel = '' if 'channel' in parameters: channel = unhexlify(parameters['channel'][0]) - return first, last, sort_by, sort_asc, query_filter, channel + return first, last, sort_by, sort_asc, query_filter, hide_xxx, channel class SpecificChannelTorrentsEndpoint(BaseTorrentsEndpoint): @@ -182,10 +185,10 @@ def __init__(self, session, channel_pk): @db_session def render_GET(self, request): - first, last, sort_by, sort_asc, query_filter, _ = \ + first, last, sort_by, sort_asc, query_filter, hide_xxx, _ = \ SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, self.channel_pk) + first, last, sort_by, sort_asc, query_filter, self.channel_pk, hide_xxx=hide_xxx) torrents = [torrent.to_simple_dict() for torrent in torrents] diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index aec7d9ef5f1..c3b393532f9 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -110,12 +110,12 @@ def render_GET(self, request): return json.dumps({"error": "your channel has not been created"}) request.args['channel'] = [str(my_channel.public_key).encode('hex')] - first, last, sort_by, sort_asc, query_filter, channel = \ + first, last, sort_by, sort_asc, query_filter, hide_xxx, channel = \ SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) exclude_deleted = 'exclude_deleted' in request.args and request.args['exclude_deleted'] torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, channel, exclude_deleted=exclude_deleted) + first, last, sort_by, sort_asc, query_filter, channel, exclude_deleted=exclude_deleted, hide_xxx=hide_xxx) torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index db11e8a5521..5758ded8933 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -47,18 +47,19 @@ def sanitize_parameters(parameters): """ Sanitize the parameters and check whether they exist """ + #TODO: unify this wit MetadataEndpoint first = 1 if 'first' not in parameters else int(parameters['first'][0]) last = 50 if 'last' not in parameters else int(parameters['last'][0]) sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) data_type = None if 'type' not in parameters else parameters['type'][0] + hide_xxx = False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0) if sort_by: sort_by = SearchEndpoint.convert_sort_param_to_pony_col(sort_by) - return first, last, sort_by, sort_asc, data_type + return first, last, sort_by, sort_asc, data_type, hide_xxx - @db_session def render_GET(self, request): """ .. http:get:: /search?q=(string:query) @@ -108,7 +109,7 @@ def render_GET(self, request): request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "q parameter missing"}) - first, last, sort_by, sort_asc, data_type = SearchEndpoint.sanitize_parameters(request.args) + first, last, sort_by, sort_asc, data_type, hide_xxx = SearchEndpoint.sanitize_parameters(request.args) query = cast_to_unicode_utf8(request.args['q'][0]) if not data_type: @@ -120,18 +121,22 @@ def render_GET(self, request): else: return json.dumps({"error": "Trying to query for unknown type of metadata"}) - results = self.session.lm.mds.TorrentMetadata.get_entries_query( - sort_by, sort_asc, query_filter=query).where(search_scope) + with db_session: + pony_query= self.session.lm.mds.TorrentMetadata.get_entries_query( + sort_by, sort_asc, query_filter=query).where(search_scope) + if hide_xxx: + pony_query = pony_query.where(lambda g: g.xxx == 0) + total = orm.count(pony_query) + search_results = [(dict(type={REGULAR_TORRENT: 'torrent', CHANNEL_TORRENT: 'channel'}[r.metadata_type], + **(r.to_simple_dict()))) for r in pony_query] - search_results = [(dict(type={REGULAR_TORRENT: 'torrent', CHANNEL_TORRENT: 'channel'}[r.metadata_type], - **(r.to_simple_dict()))) for r in results] return json.dumps({ "results": search_results[first - 1:last], "first": first, "last": last, "sort_by": sort_by, "sort_asc": sort_asc, - "total": orm.count(results) + "total": total }) @@ -171,5 +176,6 @@ def render_GET(self, request): return json.dumps({"error": "query parameter missing"}) keywords = cast_to_unicode_utf8(request.args['q'][0]).lower() + # TODO: add XXX filtering for completion terms results = self.session.lm.mds.TorrentMetadata.get_auto_complete_terms(keywords, max_terms=5) return json.dumps({"completions": results}) diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py index fd42efd6573..e84b71f9a15 100644 --- a/Tribler/Core/Upgrade/db72_to_pony.py +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -83,7 +83,7 @@ def get_old_channels(self): "public_key": dispesy_cid_to_pk(id_), "timestamp": final_timestamp(), "votes": int(nr_favorite or 0), - "xxx": float(nr_spam or 0), + #"xxx": float(nr_spam or 0), "origin_id": 0, "signature": pseudo_signature(), "skip_key_check": True, @@ -241,7 +241,8 @@ def convert_discovered_torrents(self): v = self.mds.MiscData.get(name=CONVERSION_FROM_72) if v: offset = orm.count( - g for g in self.mds.TorrentMetadata if g.status == LEGACY_ENTRY and g.metadata_type == REGULAR_TORRENT) + g for g in self.mds.TorrentMetadata if + g.status == LEGACY_ENTRY and g.metadata_type == REGULAR_TORRENT) v.set(value=DISCOVERED_CONVERSION_STARTED) else: self.mds.MiscData(name=CONVERSION_FROM_72, value=DISCOVERED_CONVERSION_STARTED) @@ -285,8 +286,6 @@ def convert_discovered_channels(self): else: self.mds.MiscData(name=CONVERSION_FROM_72, value=CHANNELS_CONVERSION_STARTED) - - with db_session: old_channels = self.get_old_channels() for c in old_channels: diff --git a/Tribler/Test/Core/Category/test_category.py b/Tribler/Test/Core/Category/test_category.py index 39c7502bd64..9a610f183bc 100644 --- a/Tribler/Test/Core/Category/test_category.py +++ b/Tribler/Test/Core/Category/test_category.py @@ -1,5 +1,6 @@ from twisted.internet.defer import inlineCallbacks -from Tribler.Core.Category.Category import Category, cmp_rank +from Tribler.Core.Category.Category import Category, cmp_rank, default_category_filter +from Tribler.Core.Category.FamilyFilter import default_xxx_filter from Tribler.Test.test_as_server import AbstractServer @@ -7,15 +8,11 @@ class TriblerCategoryTest(AbstractServer): def setUp(self): super(TriblerCategoryTest, self).setUp() - self.category = Category() - self.category.xxx_filter.xxx_terms.add("term1") - - def test_category_names_none_names(self): - self.category.category_info = None - self.assertFalse(self.category.getCategoryNames()) + self.category = default_category_filter + default_xxx_filter.xxx_terms.add("term1") def test_get_category_names(self): - self.assertEquals(len(self.category.category_info), 9) + self.assertEquals(len(self.category.category_info), 10) def test_calculate_category_multi_file(self): torrent_info = {"info": {"files": [{"path": "/my/path/video.avi", "length": 1234}]}, @@ -30,28 +27,17 @@ def test_calculate_category_single_file(self): def test_calculate_category_xxx(self): torrent_info = {"info": {"name": "term1", "length": 1234}, "announce-list": [["http://tracker.org"]], "comment": "lorem ipsum"} - self.assertEquals(self.category.calculateCategory(torrent_info, "my torrent"), 'xxx') + self.assertEquals('xxx', self.category.calculateCategory(torrent_info, "my torrent")) def test_calculate_category_invalid_announce_list(self): torrent_info = {"info": {"name": "term1", "length": 1234}, "announce-list": [[]], "comment": "lorem ipsum"} self.assertEquals(self.category.calculateCategory(torrent_info, "my torrent"), 'xxx') - def test_get_family_filter_sql(self): - self.assertFalse(self.category.get_family_filter_sql()) - self.category.set_family_filter(b=True) - self.assertTrue(self.category.get_family_filter_sql()) - def test_cmp_rank(self): self.assertEquals(cmp_rank({'bla': 3}, {'bla': 4}), 1) self.assertEquals(cmp_rank({'rank': 3}, {'bla': 4}), -1) - def test_non_existent_conf_file(self): - import Tribler.Core.Category.Category as category_file - category_file.CATEGORY_CONFIG_FILE = "thisfiledoesnotexist.conf" - test_category = Category() - self.assertEqual(test_category.category_info, []) - @inlineCallbacks def tearDown(self): import Tribler.Core.Category.Category as category_file diff --git a/Tribler/Test/Core/Category/test_family_filter.py b/Tribler/Test/Core/Category/test_family_filter.py index 1ad713deb7b..23274c96907 100644 --- a/Tribler/Test/Core/Category/test_family_filter.py +++ b/Tribler/Test/Core/Category/test_family_filter.py @@ -11,11 +11,6 @@ def setUp(self): self.family_filter.xxx_terms.add("term2") self.family_filter.xxx_searchterms.add("term3") - def test_filter_torrent(self): - self.assertFalse(self.family_filter.isXXXTorrent(["file1.txt"], "mytorrent", "http://tracker.org")) - self.assertFalse(self.family_filter.isXXXTorrent(["file1.txt"], "mytorrent", "")) - self.assertTrue(self.family_filter.isXXXTorrent(["term1.txt"], "term2", "")) - def test_is_xxx(self): self.assertFalse(self.family_filter.isXXX(None)) self.assertTrue(self.family_filter.isXXX("term1")) @@ -28,8 +23,10 @@ def test_is_xxx_term(self): self.assertTrue(self.family_filter.isXXXTerm("term1s")) self.assertFalse(self.family_filter.isXXXTerm("term0n")) - def test_invalid_filename_exception(self): - terms, searchterms = self.family_filter.initTerms("thisfiledoesnotexist.txt") - self.assertEqual(len(terms), 0) - self.assertEqual(len(searchterms), 0) - + def test_xxx_torrent_metadata_dict(self): + d = { + "title": "XXX", + "tags": "", + "tracker": "http://sooo.dfd/announce" + } + self.assertTrue(self.family_filter.isXXXTorrentMetadataDict(d)) diff --git a/Tribler/Test/Core/Config/test_tribler_config.py b/Tribler/Test/Core/Config/test_tribler_config.py index 8b2e9c90193..49d86aab6fa 100644 --- a/Tribler/Test/Core/Config/test_tribler_config.py +++ b/Tribler/Test/Core/Config/test_tribler_config.py @@ -104,9 +104,6 @@ def test_get_set_methods_general(self): """ Check whether general get and set methods are working as expected. """ - self.tribler_config.set_family_filter_enabled(False) - self.assertEqual(self.tribler_config.get_family_filter_enabled(), False) - self.tribler_config.set_state_dir("TEST") self.assertEqual(self.tribler_config.get_state_dir(), "TEST") diff --git a/Tribler/Test/Core/Modules/RestApi/base_api_test.py b/Tribler/Test/Core/Modules/RestApi/base_api_test.py index f9c063a4ac2..9e0ac88f09c 100644 --- a/Tribler/Test/Core/Modules/RestApi/base_api_test.py +++ b/Tribler/Test/Core/Modules/RestApi/base_api_test.py @@ -46,9 +46,6 @@ class AbstractBaseApiTest(TestAsServer): def setUp(self): yield super(AbstractBaseApiTest, self).setUp() self.connection_pool = HTTPConnectionPool(reactor, False) - terms = self.session.lm.category.xxx_filter.xxx_terms - terms.add("badterm") - self.session.lm.category.xxx_filter.xxx_terms = terms @inlineCallbacks def tearDown(self): diff --git a/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py index 57457ce3cc1..79ed11e2742 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_settings_endpoint.py @@ -98,16 +98,12 @@ def test_set_settings(self): download.get_credit_mining = lambda: False self.session.get_downloads = lambda: [download] - old_filter_setting = self.session.config.get_family_filter_enabled() - def verify_response1(_): - self.assertNotEqual(self.session.config.get_family_filter_enabled(), old_filter_setting) self.assertEqual(download.get_seeding_mode(), 'time') self.assertEqual(download.get_seeding_time(), 100) self.should_check_equality = False - post_data = json.dumps({'general': {'family_filter': not old_filter_setting}, - 'libtorrent': {'utp': False, 'max_download_rate': 50}, + post_data = json.dumps({'libtorrent': {'utp': False, 'max_download_rate': 50}, 'download_defaults': {'seeding_mode': 'time', 'seeding_time': 100}}) yield self.do_request('settings', expected_code=200, request_type='POST', post_data=post_data, raw_data=True) \ .addCallback(verify_response1) diff --git a/TriblerGUI/qt_resources/mainwindow.ui b/TriblerGUI/qt_resources/mainwindow.ui index e41b6a4a93f..6ff8153f1e4 100644 --- a/TriblerGUI/qt_resources/mainwindow.ui +++ b/TriblerGUI/qt_resources/mainwindow.ui @@ -4502,7 +4502,8 @@ color: white; - Family filter enabled? + Family filter enabled? +(requires Tribler restart) diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index b4c580b3582..88a4c50739d 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -152,7 +152,7 @@ def on_state_update(new_state): self.left_menu_button_downloads, self.left_menu_button_discovered] self.video_player_page.initialize_player() - self.search_results_page.initialize_search_results_page() + self.search_results_page.initialize_search_results_page(self.gui_settings) self.settings_page.initialize_settings_page() self.subscribed_channels_page.initialize() self.edit_channel_page.initialize_edit_channel_page(self.gui_settings) @@ -160,8 +160,8 @@ def on_state_update(new_state): self.home_page.initialize_home_page() self.loading_page.initialize_loading_page() self.discovering_page.initialize_discovering_page() - self.discovered_page.initialize_discovered_page() - self.channel_page.initialize_channel_page() + self.discovered_page.initialize_discovered_page(self.gui_settings) + self.channel_page.initialize_channel_page(self.gui_settings) self.trust_page.initialize_trust_page() self.token_mining_page.initialize_token_mining_page() diff --git a/TriblerGUI/widgets/channelpage.py b/TriblerGUI/widgets/channelpage.py index 6fd5fb29acc..6da2a7db3a1 100644 --- a/TriblerGUI/widgets/channelpage.py +++ b/TriblerGUI/widgets/channelpage.py @@ -3,7 +3,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget -from TriblerGUI.utilities import get_image_path +from TriblerGUI.utilities import get_image_path, get_gui_setting from TriblerGUI.widgets.tablecontentmodel import TorrentsContentModel from TriblerGUI.widgets.triblertablecontrollers import TorrentsTableViewController @@ -18,9 +18,12 @@ def __init__(self): self.channel_info = None self.model = None self.controller = None + self.gui_settings = None - def initialize_channel_page(self): - self.model = TorrentsContentModel() + def initialize_channel_page(self, gui_settings): + self.gui_settings = gui_settings + self.model = TorrentsContentModel(hide_xxx=get_gui_setting(self.gui_settings, "family_filter", True, + is_bool=True) if self.gui_settings else True) self.controller = TorrentsTableViewController(self.model, self.window().channel_page_container, None, self.window().channel_torrents_filter_input) diff --git a/TriblerGUI/widgets/discoveredpage.py b/TriblerGUI/widgets/discoveredpage.py index 6ae7ba19fae..f52e9bc26cc 100644 --- a/TriblerGUI/widgets/discoveredpage.py +++ b/TriblerGUI/widgets/discoveredpage.py @@ -2,6 +2,7 @@ from PyQt5.QtWidgets import QWidget +from TriblerGUI.utilities import get_gui_setting from TriblerGUI.widgets.tablecontentmodel import ChannelsContentModel from TriblerGUI.widgets.triblertablecontrollers import ChannelsTableViewController @@ -16,11 +17,14 @@ def __init__(self): self.initialized = False self.model = None self.controller = None + self.gui_settings = None - def initialize_discovered_page(self): + def initialize_discovered_page(self, gui_settings): if not self.initialized: self.initialized = True - self.model = ChannelsContentModel() + self.gui_settings = gui_settings + self.model = ChannelsContentModel(hide_xxx=get_gui_setting(self.gui_settings, "family_filter", True, + is_bool=True) if self.gui_settings else True) self.controller = ChannelsTableViewController(self.model, self.window().discovered_channels_list, self.window().num_discovered_channels_label, self.window().discovered_channels_filter_input) diff --git a/TriblerGUI/widgets/searchresultspage.py b/TriblerGUI/widgets/searchresultspage.py index 6eac95819d5..1c255c20a3a 100644 --- a/TriblerGUI/widgets/searchresultspage.py +++ b/TriblerGUI/widgets/searchresultspage.py @@ -2,6 +2,7 @@ from PyQt5.QtWidgets import QWidget +from TriblerGUI.utilities import get_gui_setting from TriblerGUI.widgets.tablecontentmodel import SearchResultsContentModel from TriblerGUI.widgets.triblertablecontrollers import SearchResultsTableViewController @@ -17,11 +18,14 @@ def __init__(self): self.query = None self.controller = None self.model = None + self.gui_settings = None - def initialize_search_results_page(self): + def initialize_search_results_page(self, gui_settings): + self.gui_settings = gui_settings self.window().search_results_tab.initialize() self.window().search_results_tab.clicked_tab_button.connect(self.clicked_tab_button) - self.model = SearchResultsContentModel() + self.model = SearchResultsContentModel(hide_xxx=get_gui_setting(self.gui_settings, "family_filter", True, + is_bool=True) if self.gui_settings else True) self.controller = SearchResultsTableViewController(self.model, self.window().search_results_list, self.window().search_details_container, self.window().num_search_results_label) diff --git a/TriblerGUI/widgets/settingspage.py b/TriblerGUI/widgets/settingspage.py index 35ad67347b8..5890b69c265 100644 --- a/TriblerGUI/widgets/settingspage.py +++ b/TriblerGUI/widgets/settingspage.py @@ -50,6 +50,7 @@ def initialize_settings_page(self): self.window().watch_folder_chooser_button.clicked.connect(self.on_choose_watch_dir_clicked) self.window().channel_autocommit_checkbox.stateChanged.connect(self.on_channel_autocommit_checkbox_changed) + self.window().family_filter_checkbox.stateChanged.connect(self.on_family_filter_checkbox_changed) self.window().developer_mode_enabled_checkbox.stateChanged.connect(self.on_developer_mode_checkbox_changed) self.window().use_monochrome_icon_checkbox.stateChanged.connect(self.on_use_monochrome_icon_checkbox_changed) self.window().download_settings_anon_checkbox.stateChanged.connect(self.on_anon_download_state_changed) @@ -158,6 +159,9 @@ def on_emptying_tokens(self, data): def on_channel_autocommit_checkbox_changed(self, _): self.window().gui_settings.setValue("autocommit_enabled", self.window().channel_autocommit_checkbox.isChecked()) + def on_family_filter_checkbox_changed(self, _): + self.window().gui_settings.setValue("family_filter", self.window().family_filter_checkbox.isChecked()) + def on_developer_mode_checkbox_changed(self, _): self.window().gui_settings.setValue("debug", self.window().developer_mode_enabled_checkbox.isChecked()) self.window().left_menu_button_debug.setHidden(not self.window().developer_mode_enabled_checkbox.isChecked()) @@ -217,7 +221,8 @@ def initialize_with_settings(self, settings): gui_settings = self.window().gui_settings # General settings - self.window().family_filter_checkbox.setChecked(settings['general']['family_filter']) + self.window().family_filter_checkbox.setChecked(get_gui_setting(gui_settings, 'family_filter', + True, is_bool=True)) self.window().use_monochrome_icon_checkbox.setChecked(get_gui_setting(gui_settings, "use_monochrome_icon", False, is_bool=True)) self.window().download_location_input.setText(settings['download_defaults']['saveas']) @@ -230,7 +235,8 @@ def initialize_with_settings(self, settings): self.window().watchfolder_location_input.setText(settings['watch_folder']['directory']) # Channel settings - self.window().channel_autocommit_checkbox.setChecked(get_gui_setting(gui_settings, "autocommit_enabled", True, is_bool=True)) + self.window().channel_autocommit_checkbox.setChecked( + get_gui_setting(gui_settings, "autocommit_enabled", True, is_bool=True)) # Log directory self.window().log_location_input.setText(settings['general']['log_dir']) @@ -334,7 +340,6 @@ def save_settings(self): settings_data = {'general': {}, 'Tribler': {}, 'download_defaults': {}, 'libtorrent': {}, 'watch_folder': {}, 'tunnel_community': {}, 'trustchain': {}, 'credit_mining': {}, 'resource_monitor': {}, 'ipv8': {}, 'chant': {}} - settings_data['general']['family_filter'] = self.window().family_filter_checkbox.isChecked() settings_data['download_defaults']['saveas'] = self.window().download_location_input.text().encode('utf-8') settings_data['general']['log_dir'] = self.window().log_location_input.text() @@ -455,6 +460,8 @@ def on_settings_saved(self, data): if not data: return # Now save the GUI settings + self.window().gui_settings.setValue("family_filter", + self.window().family_filter_checkbox.isChecked()) self.window().gui_settings.setValue("autocommit_enabled", self.window().channel_autocommit_checkbox.isChecked()) self.window().gui_settings.setValue("ask_download_settings", diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index 74b22b6cfc6..354977323c1 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -57,11 +57,12 @@ class TriblerContentModel(RemoteTableModel): column_flags = {} column_display_filters = {} - def __init__(self): + def __init__(self, hide_xxx=False): RemoteTableModel.__init__(self, parent=None) self.data_items = [] self.column_position = {name: i for i, name in enumerate(self.columns)} self.edit_enabled = False + self.hide_xxx = hide_xxx def headerData(self, num, orientation, role=None): if orientation == Qt.Horizontal and role == Qt.DisplayRole: @@ -103,8 +104,8 @@ class SearchResultsContentModel(TriblerContentModel): ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable } - def __init__(self): - TriblerContentModel.__init__(self) + def __init__(self, **kwargs): + TriblerContentModel.__init__(self, **kwargs) self.type_filter = None @@ -121,8 +122,8 @@ class ChannelsContentModel(TriblerContentModel): ACTION_BUTTONS: Qt.ItemIsEnabled } - def __init__(self, subscribed=False): - TriblerContentModel.__init__(self) + def __init__(self, subscribed=False, **kwargs): + TriblerContentModel.__init__(self, **kwargs) self.subscribed = subscribed @@ -141,8 +142,8 @@ class TorrentsContentModel(TriblerContentModel): u'size': lambda data: format_size(float(data)), } - def __init__(self, channel_pk=''): - TriblerContentModel.__init__(self) + def __init__(self, channel_pk='', **kwargs): + TriblerContentModel.__init__(self, **kwargs) self.channel_pk = channel_pk @@ -157,7 +158,7 @@ class MyTorrentsContentModel(TorrentsContentModel): ACTION_BUTTONS: Qt.ItemIsEnabled | Qt.ItemIsSelectable } - def __init__(self, channel_pk=''): - TorrentsContentModel.__init__(self, channel_pk=channel_pk) + def __init__(self, channel_pk='', **kwargs): + TorrentsContentModel.__init__(self, channel_pk=channel_pk, **kwargs) self.exclude_deleted = False self.edit_enabled = True diff --git a/TriblerGUI/widgets/triblertablecontrollers.py b/TriblerGUI/widgets/triblertablecontrollers.py index 38e21369bed..51592fd17c3 100644 --- a/TriblerGUI/widgets/triblertablecontrollers.py +++ b/TriblerGUI/widgets/triblertablecontrollers.py @@ -103,6 +103,7 @@ def load_search_results(self, query, start=None, end=None): "last": end if end else '', "sort_by": sort_by if sort_by else '', "sort_asc": sort_asc, + "hide_xxx": self.model.hide_xxx, "type": self.model.type_filter if self.model.type_filter else '' } self.request_mgr = TriblerRequestManager() @@ -171,6 +172,7 @@ def load_channels(self, start=None, end=None): "sort_by": sort_by, "sort_asc": sort_asc, "filter": to_fts_query(filter_text), + "hide_xxx": self.model.hide_xxx, "subscribed": self.model.subscribed}) def on_channels(self, response): @@ -239,13 +241,14 @@ def load_torrents(self, start=None, end=None): self.request_mgr = TriblerRequestManager() self.request_mgr.perform_request( - "metadata/channels/%s/torrents?" % self.model.channel_pk, + "metadata/channels/%s/torrents" % self.model.channel_pk, self.on_torrents, url_params={ "first": start, "last": end, "sort_by": sort_by, "sort_asc": sort_asc, + "hide_xxx": self.model.hide_xxx, "filter": to_fts_query(filter_text)}) def on_torrents(self, response): From 29129b26f5db8161d1f6e7b0e9069683ffb2a6a8 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Thu, 7 Feb 2019 13:55:34 +0100 Subject: [PATCH 26/38] GUI fixes --- TriblerGUI/tribler_window.py | 7 ++++--- TriblerGUI/widgets/discoveredpage.py | 4 ++++ TriblerGUI/widgets/subscriptionswidget.py | 8 ++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/TriblerGUI/tribler_window.py b/TriblerGUI/tribler_window.py index 88a4c50739d..ff6876d57b4 100644 --- a/TriblerGUI/tribler_window.py +++ b/TriblerGUI/tribler_window.py @@ -806,8 +806,8 @@ def show_left_menu_playlist(self): self.left_menu_playlist_label.setHidden(False) self.left_menu_playlist.setHidden(False) - def on_channel_clicked(self, public_key): - self.channel_page.initialize_with_channel(public_key) + def on_channel_clicked(self, channel_info): + self.channel_page.initialize_with_channel(channel_info) self.navigation_stack.append(self.stackedWidget.currentIndex()) self.stackedWidget.setCurrentIndex(PAGE_CHANNEL_DETAILS) @@ -852,7 +852,8 @@ def show_force_shutdown(): self.show_loading_screen() self.hide_status_bar() self.loading_text_label.setText("Shutting down...") - self.debug_window.setHidden(True) + if self.debug_window: + self.debug_window.setHidden(True) self.shutdown_timer = QTimer() self.shutdown_timer.timeout.connect(show_force_shutdown) diff --git a/TriblerGUI/widgets/discoveredpage.py b/TriblerGUI/widgets/discoveredpage.py index f52e9bc26cc..ac0413c4f4b 100644 --- a/TriblerGUI/widgets/discoveredpage.py +++ b/TriblerGUI/widgets/discoveredpage.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from PyQt5.QtCore import Qt from PyQt5.QtWidgets import QWidget from TriblerGUI.utilities import get_gui_setting @@ -25,6 +26,9 @@ def initialize_discovered_page(self, gui_settings): self.gui_settings = gui_settings self.model = ChannelsContentModel(hide_xxx=get_gui_setting(self.gui_settings, "family_filter", True, is_bool=True) if self.gui_settings else True) + # Set the default sorting column/order to num_torrents/descending + default_sort_column = self.model.columns.index(u'torrents') + self.window().discovered_channels_list.horizontalHeader().setSortIndicator(default_sort_column, Qt.AscendingOrder) self.controller = ChannelsTableViewController(self.model, self.window().discovered_channels_list, self.window().num_discovered_channels_label, self.window().discovered_channels_filter_input) diff --git a/TriblerGUI/widgets/subscriptionswidget.py b/TriblerGUI/widgets/subscriptionswidget.py index 5292969db27..f20def72cb9 100644 --- a/TriblerGUI/widgets/subscriptionswidget.py +++ b/TriblerGUI/widgets/subscriptionswidget.py @@ -41,6 +41,7 @@ def initialize_with_channel(self, channel): self.update_subscribe_button() def update_subscribe_button(self, remote_response=None): + if remote_response and 'subscribed' in remote_response: self.channel_info["subscribed"] = remote_response['subscribed'] @@ -63,6 +64,13 @@ def update_subscribe_button(self, remote_response=None): else: self.credit_mining_button.hide() + # Disable channel control buttons for LEGACY_ENTRY channels + hide_controls = (self.channel_info["status"] == 6) + self.num_subs_label.setHidden(hide_controls) + self.subscribe_button.setHidden( + hide_controls or ("my_channel" in self.channel_info and self.channel_info["my_channel"])) + self.credit_mining_button.setHidden(hide_controls) + def on_subscribe_button_click(self): self.request_mgr = TriblerRequestManager() if int(self.channel_info["subscribed"]): From 19779ab28cb123f4d1ca8ea82416e7223f947ed2 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Thu, 7 Feb 2019 15:38:06 +0100 Subject: [PATCH 27/38] fixes to GigachannelManager --- .../Core/Libtorrent/LibtorrentDownloadImpl.py | 4 +- Tribler/Core/Modules/gigachannel_manager.py | 50 ++++++++++++------- .../Core/Modules/restapi/metadata_endpoint.py | 1 + Tribler/Core/Upgrade/db72_to_pony.py | 2 +- 4 files changed, 35 insertions(+), 22 deletions(-) diff --git a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py index 3557e1a3252..62235f941b9 100644 --- a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py +++ b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py @@ -621,9 +621,9 @@ def on_torrent_finished_alert(self, alert): if self.finished_callback_already_called: self._logger.warning("LibtorrentDownloadImpl: tried to repeat the call to finished_callback %s", self.tdef.get_name()) - else: - self.finished_callback_already_called = True + self.finished_callback(self) + self.finished_callback_already_called = True progress = self.get_state().get_progress() if self.get_mode() == DLMODE_VOD: diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index 66bcf6d9caa..f999188790d 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -2,6 +2,7 @@ from binascii import hexlify from pony.orm import db_session +from twisted.internet import task, reactor from twisted.internet.defer import Deferred from twisted.internet.task import LoopingCall @@ -33,11 +34,11 @@ def start(self): lcall = LoopingCall(self.service_channels) d = self.register_task("Process channels download queue and remove cruft", lcall).start(channels_check_interval) - #def handle(f): + # def handle(f): # print "errback" # print "we got an exception: %s" % (f.getTraceback(),) # f.trap(RuntimeError) - #d.addErrback(handle) + # d.addErrback(handle) def shutdown(self): """ @@ -101,11 +102,12 @@ def on_channel_download_finished(self, download, channel_id, finished_deferred=N :param channel_id: The ID of the channel. :param finished_deferred: An optional deferred that should fire if the channel download has finished. """ - if download.get_channel_download(): - channel_dirname = os.path.join(self.session.lm.mds.channels_dir, download.get_def().get_name()) - self.session.lm.mds.process_channel_dir(channel_dirname, channel_id) - if finished_deferred: - finished_deferred.callback(download) + if download.finished_callback_already_called: + return + channel_dirname = os.path.join(self.session.lm.mds.channels_dir, download.get_def().get_name()) + self.session.lm.mds.process_channel_dir(channel_dirname, channel_id) + if finished_deferred: + finished_deferred.callback(download) @db_session def remove_channel(self, channel): @@ -118,7 +120,8 @@ def remove_channel(self, channel): channel.local_version = 0 # Remove all stuff matching the channel dir name / public key / torrent title - remove_list = [(d, True) for d in self.session.lm.get_channel_downloads() if d.tdef.get_name_utf8() == channel.dir_name] + remove_list = [(d, True) for d in self.session.lm.get_channel_downloads() if + d.tdef.get_name_utf8() == channel.dir_name] self.remove_channels_downloads(remove_list) # TODO: finish this routine @@ -197,14 +200,23 @@ def updated_my_channel(self): """ Notify the core that we updated our channel. """ - my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() - if my_channel and my_channel.status == COMMITTED and \ - not self.session.has_download(str(my_channel.infohash)): - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - else: - return - tdef = TorrentDef.load(torrent_path) - dcfg = DownloadStartupConfig() - dcfg.set_dest_dir(self.session.lm.mds.channels_dir) - dcfg.set_channel_download(True) - self.session.lm.add(tdef, dcfg) + try: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if my_channel and my_channel.status == COMMITTED and \ + not self.session.has_download(str(my_channel.infohash)): + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + else: + return + + tdef = TorrentDef.load(torrent_path) + dcfg = DownloadStartupConfig() + dcfg.set_dest_dir(self.session.lm.mds.channels_dir) + dcfg.set_channel_download(True) + self.session.lm.add(tdef, dcfg) + except: + # Ugly recursive workaround for race condition when the torrent file is not there + # FIXME: stop using intermediary torrent file for personal channel + taskname = "updated_my_channel delayed attempt" + if not self.is_pending_task_active(taskname): + d = task.deferLater(reactor, 7, self.updated_my_channel) + self.register_task(taskname, d) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 5adc2a98bba..6fd4c627d51 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -153,6 +153,7 @@ def render_POST(self, request): return json.dumps({"error": "this channel cannot be found"}) channel.subscribed = to_subscribe + channel.local_version = 0 return json.dumps({"success": True, "subscribed": to_subscribe}) diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py index e84b71f9a15..e8f891e50d4 100644 --- a/Tribler/Core/Upgrade/db72_to_pony.py +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -207,7 +207,7 @@ def convert_personal_channel(self): for g in my_channel.contents_list: g.delete() my_channel.delete() - elif v.value == DISCOVERED_CONVERSION_STARTED: + elif v.value == CHANNELS_CONVERSION_STARTED: v.set(value=PERSONAL_CONVERSION_STARTED) else: return From 37b6a6b69d54790d073ca0906da4f996c9d61dab Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Thu, 7 Feb 2019 22:07:43 +0100 Subject: [PATCH 28/38] minimize db_session usage --- .../Core/Modules/restapi/metadata_endpoint.py | 34 +++++++++---------- TriblerGUI/widgets/tablecontentdelegate.py | 3 +- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 6fd4c627d51..8e494911cfa 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -184,14 +184,14 @@ def __init__(self, session, channel_pk): BaseTorrentsEndpoint.__init__(self, session) self.channel_pk = channel_pk - @db_session def render_GET(self, request): first, last, sort_by, sort_asc, query_filter, hide_xxx, _ = \ SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) - torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, self.channel_pk, hide_xxx=hide_xxx) + with db_session: + torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( + first, last, sort_by, sort_asc, query_filter, self.channel_pk, hide_xxx=hide_xxx) - torrents = [torrent.to_simple_dict() for torrent in torrents] + torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ "torrents": torrents, @@ -225,23 +225,21 @@ def __init__(self, session, infohash): self.putChild("health", TorrentHealthEndpoint(self.session, self.infohash)) - @db_session def render_GET(self, request): - md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: - g.infohash == database_blob(self.infohash))) - if not md_list: + with db_session: + md = self.session.lm.mds.TorrentMetadata.select(lambda g: g.infohash == database_blob(self.infohash))[:1] + torrent_dict = md[0].to_simple_dict(include_trackers=True) if md else None + + if not md: request.setResponseCode(http.NOT_FOUND) request.write(json.dumps({"error": "torrent not found in database"})) return - torrent = md_list[0] - - return json.dumps({"torrent": torrent.to_simple_dict(include_trackers=True)}) + return json.dumps({"torrent": torrent_dict}) class TorrentsRandomEndpoint(BaseTorrentsEndpoint): - @db_session def render_GET(self, request): limit_torrents = 10 @@ -252,8 +250,10 @@ def render_GET(self, request): request.setResponseCode(http.BAD_REQUEST) return json.dumps({"error": "the limit parameter must be a positive number"}) - random_torrents = self.session.lm.mds.TorrentMetadata.get_random_torrents(limit=limit_torrents) - return json.dumps({"torrents": [torrent.to_simple_dict() for torrent in random_torrents]}) + with db_session: + random_torrents = self.session.lm.mds.TorrentMetadata.get_random_torrents(limit=limit_torrents) + torrents = [torrent.to_simple_dict() for torrent in random_torrents] + return json.dumps({"torrents": torrents}) class TorrentHealthEndpoint(resource.Resource): @@ -331,9 +331,9 @@ def on_request_error(failure): with db_session: md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: g.infohash == database_blob(self.infohash))) - if not md_list: - request.setResponseCode(http.NOT_FOUND) - request.write(json.dumps({"error": "torrent not found in database"})) + if not md_list: + request.setResponseCode(http.NOT_FOUND) + request.write(json.dumps({"error": "torrent not found in database"})) self.session.check_torrent_health(self.infohash, timeout=timeout, scrape_now=refresh) \ .addCallback(on_health_result).addErrback(on_request_error) diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py index e58ca218f33..f74f4764359 100644 --- a/TriblerGUI/widgets/tablecontentdelegate.py +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -475,7 +475,8 @@ def paint(self, painter, rect, index): data_item = index.model().data_items[index.row()] if u'health' not in data_item: - data_item[u'health'] = get_health(data_item['num_seeders'], data_item['num_leechers'], + data_item[u'health'] = get_health(data_item['num_seeders'], + data_item['num_leechers'], data_item['last_tracker_check']) health = data_item[u'health'] From fab1b28d91013bebbfe4a9efb0ac80eee28f6458 Mon Sep 17 00:00:00 2001 From: qstokkink Date: Fri, 8 Feb 2019 14:30:58 +0100 Subject: [PATCH 29/38] Removed deprecated DualStackDiscoveryCommunity --- .../Core/APIImplementation/LaunchManyCore.py | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/Tribler/Core/APIImplementation/LaunchManyCore.py b/Tribler/Core/APIImplementation/LaunchManyCore.py index 0a03d2cf4d2..91c1498e031 100644 --- a/Tribler/Core/APIImplementation/LaunchManyCore.py +++ b/Tribler/Core/APIImplementation/LaunchManyCore.py @@ -49,22 +49,6 @@ from Tribler.pyipv8.ipv8_service import IPv8 -class DualStackDiscoveryCommunity(DiscoveryCommunity): - """ - This is a stopgap measure until Dispersy is removed. - The reason for this class is that Dispersy bypasses IPv8's load balancing. - By injecting peers into IPv8, Dispersy then causes a peer explosion. - This subclass can be removed once Dispersy is gone. - """ - - def on_introduction_response(self, source_address, data): - if self.max_peers >= 0 and len(self.get_peers()) > self.max_peers: - self.logger.info("Dropping introduction response from (%s, %d): too many peers!", - source_address[0], source_address[1]) - return - return super(DualStackDiscoveryCommunity, self).on_introduction_response(source_address, data) - - class TriblerLaunchMany(TaskManager): def __init__(self): @@ -179,7 +163,7 @@ def load_ipv8_overlays(self): peer = Peer(self.session.trustchain_testnet_keypair) else: peer = Peer(self.session.trustchain_keypair) - discovery_community = DualStackDiscoveryCommunity(peer, self.ipv8.endpoint, self.ipv8.network) + discovery_community = DiscoveryCommunity(peer, self.ipv8.endpoint, self.ipv8.network) discovery_community.resolve_dns_bootstrap_addresses() self.ipv8.overlays.append(discovery_community) self.ipv8.strategies.append((RandomChurn(discovery_community), -1)) From cf0dbfb0b37f9e5edc43658a589e430fdf3fdba0 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Sun, 10 Feb 2019 20:48:41 +0100 Subject: [PATCH 30/38] GigaChannel community refactor and news update fix --- Tribler/Core/Modules/MetadataStore/store.py | 39 ++++++++---- .../Modules/restapi/downloads_endpoint.py | 2 +- .../Community/gigachannel/test_community.py | 27 ++++++++ .../MetadataStore/test_channel_download.py | 4 +- .../Core/Modules/MetadataStore/test_store.py | 22 +++++-- Tribler/community/gigachannel/community.py | 63 +++++++++++++------ 6 files changed, 118 insertions(+), 39 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 49a732e4f2f..0323eb6d77a 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -8,7 +8,6 @@ from pony import orm from pony.orm import db_session -from Tribler.Core.Category.Category import Category from Tribler.Core.Modules.MetadataStore.OrmBindings import torrent_metadata, channel_metadata, \ torrent_state, tracker_state, channel_node, misc from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION @@ -21,6 +20,14 @@ CLOCK_STATE_FILE = "clock.state" +UNKNOWN_CHANNEL = 1 +UPDATED_OUR_VERSION = 2 +GOT_SAME_VERSION = 3 +GOT_NEWER_VERSION = 4 +UNKNOWN_TORRENT = 5 +NO_ACTION = 6 +DELETED_METADATA = 7 + sql_create_fts_table = """ CREATE VIRTUAL TABLE IF NOT EXISTS FtsIndex USING FTS5 (title, tags, content='ChannelNode', prefix = '2 3 4 5', @@ -64,7 +71,7 @@ def __init__(self, filename=None): # and lose their database. We don't know what was their channel # clock before, but at least we can assume that they were not # adding to it 1000 torrents per second constantly... - self.clock = time2int(datetime.utcnow())*1000 + self.clock = time2int(datetime.utcnow()) * 1000 # Read the clock from the disk if the filename is given if self.filename and os.path.isfile(self.filename): with open(self.filename, 'rb') as f: @@ -198,20 +205,18 @@ def process_compressed_mdblob(self, compressed_data): @db_session def process_squashed_mdblob(self, chunk_data): - metadata_list = [] + results_list = [] offset = 0 while offset < len(chunk_data): payload, offset = read_payload_with_offset(chunk_data, offset) - md = self.process_payload(payload) - if md: - metadata_list.append(md) - return metadata_list + results_list.append(self.process_payload(payload)) + return results_list # Can't use db_session wrapper here, performance drops 10 times! Pony bug! def process_payload(self, payload): with db_session: if self.ChannelNode.exists(signature=payload.signature): - return self.ChannelNode.get(signature=payload.signature) + return None, GOT_SAME_VERSION if payload.metadata_type == DELETED: # We only allow people to delete their own entries, thus PKs must match @@ -219,9 +224,11 @@ def process_payload(self, payload): public_key=payload.public_key) if existing_metadata: existing_metadata.delete() - return None + return None, DELETED_METADATA + else: + return None, NO_ACTION elif payload.metadata_type == REGULAR_TORRENT: - return self.TorrentMetadata.from_payload(payload) + return self.TorrentMetadata.from_payload(payload), UNKNOWN_TORRENT elif payload.metadata_type == CHANNEL_TORRENT: return self.update_channel_info(payload) @@ -232,6 +239,7 @@ def update_channel_info(self, payload): Validate the signature, update the local metadata store and put in at the beginning of the download queue if necessary. :param payload: The channel metadata, in serialized form. + :returns (metadata, status): tuple consisting of possibly newer metadata and result status """ channel = self.ChannelMetadata.get_channel_with_id(payload.public_key) @@ -239,9 +247,16 @@ def update_channel_info(self, payload): if payload.timestamp > channel.timestamp: # Update the channel that is already there. self._logger.info("Updating channel metadata %s ts %s->%s", str(channel.public_key).encode("hex"), - str(channel.timestamp), str(int2time(payload.timestamp))) + str(channel.timestamp), str(payload.timestamp)) channel.set(**ChannelMetadataPayload.to_dict(payload)) + status = UPDATED_OUR_VERSION + elif payload.timestamp == channel.timestamp: + status = GOT_SAME_VERSION + else: + status = GOT_NEWER_VERSION + else: + status = UNKNOWN_CHANNEL # Add new channel object to DB channel = self.ChannelMetadata.from_payload(payload) @@ -249,7 +264,7 @@ def update_channel_info(self, payload): if channel.version > channel.local_version: #TODO: handle the case where the local version is the same as the new one and is not seeded """ - return channel + return channel, status @db_session def get_my_channel(self): diff --git a/Tribler/Core/Modules/restapi/downloads_endpoint.py b/Tribler/Core/Modules/restapi/downloads_endpoint.py index f9334ec40f3..059ee993235 100644 --- a/Tribler/Core/Modules/restapi/downloads_endpoint.py +++ b/Tribler/Core/Modules/restapi/downloads_endpoint.py @@ -352,7 +352,7 @@ def on_error(error): return json.dumps({"error": "Metadata has invalid signature"}) with db_session: - channel = self.session.lm.mds.process_payload(payload) + channel, _ = self.session.lm.mds.process_payload(payload) if channel and not channel.subscribed and channel.local_version < channel.timestamp: channel.subscribed = True download, _ = self.session.lm.gigachannel_manager.download_channel(channel) diff --git a/Tribler/Test/Community/gigachannel/test_community.py b/Tribler/Test/Community/gigachannel/test_community.py index e799abf7897..b35d63de5b7 100644 --- a/Tribler/Test/Community/gigachannel/test_community.py +++ b/Tribler/Test/Community/gigachannel/test_community.py @@ -78,3 +78,30 @@ def test_send_random_multiple_torrents(self): self.assertEqual(len(self.nodes[1].overlay.metadata_store.ChannelMetadata.select()), 1) channel = self.nodes[1].overlay.metadata_store.ChannelMetadata.select()[:][0] self.assertLess(channel.contents_len, 20) + + @inlineCallbacks + def test_send_and_get_channel_update_back(self): + """ + Test if sending back information on updated version of a channel works + """ + with db_session: + # Add channel to node 0 + channel = self.nodes[0].overlay.metadata_store.ChannelMetadata.create_channel("test", "bla") + for _ in xrange(20): + self.add_random_torrent(self.nodes[0].overlay.metadata_store.TorrentMetadata) + channel.commit_channel_torrent() + channel_v1_dict = channel.to_dict() + self.add_random_torrent(self.nodes[0].overlay.metadata_store.TorrentMetadata) + channel.commit_channel_torrent() + + # Add the outdated version of the channel to node 1 + self.nodes[1].overlay.metadata_store.ChannelMetadata.from_dict(channel_v1_dict) + + # node1 --outdated_channel--> node0 + self.nodes[1].overlay.send_random_to(Peer(self.nodes[0].my_peer.public_key, self.nodes[0].endpoint.wan_address)) + + yield self.deliver_messages() + + with db_session: + self.assertEqual(self.nodes[1].overlay.metadata_store.ChannelMetadata.select()[:][0].timestamp, + self.nodes[0].overlay.metadata_store.ChannelMetadata.select()[:][0].timestamp) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py index 93749b7c176..d25d078beba 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_download.py @@ -44,7 +44,9 @@ def test_channel_update_and_download(self): payload = ChannelMetadataPayload.from_file(CHANNEL_METADATA_UPDATED) # Download the channel in our session with db_session: - channel = self.session.lm.mds.process_payload(payload) + self.session.lm.mds.process_payload(payload) + channel = self.session.lm.mds.ChannelMetadata.get(signature=payload.signature) + download, finished_deferred = self.session.lm.gigachannel_manager.download_channel(channel) download.add_peer(("127.0.0.1", self.seeder_session.config.get_libtorrent_port())) yield finished_deferred diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index ea529599390..2550186a0b9 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -59,14 +59,14 @@ def test_process_channel_dir_file(self): # We delete this TorrentMeta info now, it should be added again to the database when loading it test_torrent_metadata.delete() loaded_metadata = self.mds.process_mdblob_file(metadata_path) - self.assertEqual(loaded_metadata[0].title, 'test') + self.assertEqual(loaded_metadata[0][0].title, 'test') # Test whether we delete existing metadata when loading a DeletedMetadata blob metadata = self.mds.TorrentMetadata(infohash='1' * 20) metadata.to_delete_file(metadata_path) loaded_metadata = self.mds.process_mdblob_file(metadata_path) # Make sure the original metadata is deleted - self.assertListEqual(loaded_metadata, []) + self.assertEqual(loaded_metadata[0], (None, 7)) self.assertIsNone(self.mds.TorrentMetadata.get(infohash='1' * 20)) # Test an unknown metadata type, this should raise an exception @@ -81,13 +81,25 @@ def test_squash_mdblobs(self): title=''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))) for _ in range(0, 10)] chunk, _ = entries_to_chunk(md_list, chunk_size=chunk_size) - self.assertItemsEqual(md_list, self.mds.process_compressed_mdblob(chunk)) + dict_list = [d.to_dict()["signature"] for d in md_list] + for d in md_list: + d.delete() + self.assertListEqual(dict_list, [d[0].to_dict()["signature"] for d in self.mds.process_compressed_mdblob(chunk)]) + @db_session + def test_squash_mdblobs_multiple_chunks(self): + chunk_size = self.mds.ChannelMetadata._CHUNK_SIZE_LIMIT + md_list = [self.mds.TorrentMetadata( + title=''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(20))) for _ in + range(0, 10)] # Test splitting into multiple chunks chunk, index = entries_to_chunk(md_list, chunk_size=900) chunk2, _ = entries_to_chunk(md_list, chunk_size=900, start_index=index) - self.assertItemsEqual(md_list[:index], self.mds.process_compressed_mdblob(chunk)) - self.assertItemsEqual(md_list[index:], self.mds.process_compressed_mdblob(chunk2)) + dict_list = [d.to_dict()["signature"] for d in md_list] + for d in md_list: + d.delete() + self.assertListEqual(dict_list[:index], [d[0].to_dict()["signature"] for d in self.mds.process_compressed_mdblob(chunk)]) + self.assertListEqual(dict_list[index:], [d[0].to_dict()["signature"] for d in self.mds.process_compressed_mdblob(chunk2)]) @db_session def test_multiple_squashed_commit_and_read(self): diff --git a/Tribler/community/gigachannel/community.py b/Tribler/community/gigachannel/community.py index b52530f1ce0..1122a7b5c4a 100644 --- a/Tribler/community/gigachannel/community.py +++ b/Tribler/community/gigachannel/community.py @@ -3,11 +3,33 @@ from pony.orm import db_session from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk +from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT +from Tribler.Core.Modules.MetadataStore.store import GOT_NEWER_VERSION from Tribler.pyipv8.ipv8.community import Community -from Tribler.pyipv8.ipv8.lazy_community import PacketDecodingError +from Tribler.pyipv8.ipv8.lazy_community import lazy_wrapper +from Tribler.pyipv8.ipv8.messaging.payload import Payload from Tribler.pyipv8.ipv8.messaging.payload_headers import BinMemberAuthenticationPayload from Tribler.pyipv8.ipv8.peer import Peer +minimal_blob_size = 200 +maximum_payload_size = 1024 +max_entries = maximum_payload_size / minimal_blob_size + + +class RawBlobPayload(Payload): + format_list = ['raw'] + + def __init__(self, raw_blob): + super(RawBlobPayload, self).__init__() + self.raw_blob = raw_blob + + def to_pack_list(self): + return [('raw', self.raw_blob)] + + @classmethod + def from_unpack_list(cls, raw_blob): + return RawBlobPayload(raw_blob) + class GigaChannelCommunity(Community): """ @@ -20,12 +42,15 @@ class GigaChannelCommunity(Community): "daa8556605043c6da4db7d26113cba9f9cbe63fddf74625117598317e05cb5b8cbd606d0911683570ad" "bb921c91")) + NEWS_PUSH_MESSAGE = 1 + def __init__(self, my_peer, endpoint, network, metadata_store): super(GigaChannelCommunity, self).__init__(my_peer, endpoint, network) self.metadata_store = metadata_store + self.auth = BinMemberAuthenticationPayload(self.my_peer.public_key.key_to_bin()).to_pack_list() self.decode_map.update({ - chr(1): self.on_blob + chr(self.NEWS_PUSH_MESSAGE): self.on_blob }) def send_random_to(self, peer): @@ -36,10 +61,6 @@ def send_random_to(self, peer): :type peer: Peer :returns: None """ - minimal_blob_size = 200 - maximum_payload_size = 1024 - max_entries = maximum_payload_size / minimal_blob_size - # Choose some random entries and try to pack them into maximum_payload_size bytes md_list = [] with db_session: @@ -51,27 +72,29 @@ def send_random_to(self, peer): md_list.append(channel) md_list.extend(list(channel.get_random_torrents(max_entries - 1))) blob = entries_to_chunk(md_list, maximum_payload_size)[0] if md_list else None + self.endpoint.send(peer.address, self._ez_pack(self._prefix, self.NEWS_PUSH_MESSAGE, + [self.auth, RawBlobPayload(blob).to_pack_list()])) - # Send chosen entries to peer - if md_list: - auth = BinMemberAuthenticationPayload(self.my_peer.public_key.key_to_bin()).to_pack_list() - ersatz_payload = [('raw', blob)] - self.endpoint.send(peer.address, self._ez_pack(self._prefix, 1, [auth, ersatz_payload])) - - def on_blob(self, source_address, data): + @lazy_wrapper(RawBlobPayload) + def on_blob(self, peer, blob): """ Callback for when a MetadataBlob message comes in. - :param source_address: the peer that sent us the blob + :param peer: the peer that sent us the blob :param data: payload raw data """ - auth, remainder = self.serializer.unpack_to_serializables([BinMemberAuthenticationPayload, ], data[23:]) - signature_valid, remainder = self._verify_signature(auth, data) - blob = remainder[23:] - if not signature_valid: - raise PacketDecodingError("Incoming packet %s has an invalid signature" % str(self.__class__)) - self.metadata_store.process_compressed_mdblob(blob) + with db_session: + md_list = self.metadata_store.process_compressed_mdblob(blob.raw_blob) + # Check if the guy who send us this metadata actually has an older version of this md than + # we do, and queue to send it back. + + reply_list = [md for md, result in md_list if + (md and (md.metadata_type == CHANNEL_TORRENT)) and (result == GOT_NEWER_VERSION)] + reply_blob = entries_to_chunk(reply_list, maximum_payload_size)[0] if reply_list else None + if reply_blob: + self.endpoint.send(peer.address, + self._ez_pack(self._prefix, 1, [self.auth, RawBlobPayload(reply_blob).to_pack_list()])) class GigaChannelTestnetCommunity(GigaChannelCommunity): From 7683af94e9efb0b34d2283df421e301001d6822e Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Wed, 13 Feb 2019 17:43:48 +0100 Subject: [PATCH 31/38] Fix torrentchecker --- Tribler/Core/Category/Category.py | 21 +++--- Tribler/Core/Category/FamilyFilter.py | 3 +- Tribler/Core/Category/category.conf | 49 +++++++------- .../Core/Libtorrent/LibtorrentDownloadImpl.py | 7 +- .../MetadataStore/OrmBindings/author.py | 11 ---- .../OrmBindings/channel_metadata.py | 15 ++--- .../MetadataStore/OrmBindings/channel_node.py | 8 +-- .../OrmBindings/torrent_metadata.py | 6 +- Tribler/Core/Modules/MetadataStore/store.py | 18 ++---- Tribler/Core/Modules/gigachannel_manager.py | 52 ++++++--------- .../Core/Modules/restapi/metadata_endpoint.py | 64 ++++++++----------- .../Modules/restapi/mychannel_endpoint.py | 24 +++---- .../Core/TorrentChecker/torrent_checker.py | 37 +++++------ Tribler/Core/Utilities/tracker_utils.py | 2 +- .../RestApi/test_mychannel_endpoint.py | 2 +- .../Core/Modules/test_gigachannel_manager.py | 21 ++++-- .../TorrentChecker/test_torrentchecker.py | 45 +++++++++++-- .../Test/Core/Upgrade/test_db72_to_pony.py | 4 +- .../Test/Core/Utilities/test_tracker_utils.py | 12 ++++ 19 files changed, 202 insertions(+), 199 deletions(-) delete mode 100644 Tribler/Core/Modules/MetadataStore/OrmBindings/author.py diff --git a/Tribler/Core/Category/Category.py b/Tribler/Core/Category/Category.py index fd88a6c73c2..d2d0678d183 100644 --- a/Tribler/Core/Category/Category.py +++ b/Tribler/Core/Category/Category.py @@ -18,20 +18,19 @@ def cmp_rank(a, b): - if not ('rank' in a): + if 'rank' not in a: return 1 - elif not ('rank' in b): + if 'rank' not in b: return -1 - elif a['rank'] == -1: + if a['rank'] == b['rank']: + return 0 + if a['rank'] == -1: return 1 - elif b['rank'] == -1: + if b['rank'] == -1: return -1 - elif a['rank'] == b['rank']: - return 0 - elif a['rank'] < b['rank']: + if a['rank'] < b['rank']: return -1 - else: - return 1 + return 1 class Category(object): @@ -103,9 +102,9 @@ def judge(self, category, files_list, display_name=''): pass if (1 - factor) > 0.5: if 'strength' in category: - return (True, category['strength']) + return True, category['strength'] else: - return (True, (1 - factor)) + return True, (1 - factor) # judge each file matchSize = 0 diff --git a/Tribler/Core/Category/FamilyFilter.py b/Tribler/Core/Category/FamilyFilter.py index 94a07b2c0cf..21f0b53cf5a 100644 --- a/Tribler/Core/Category/FamilyFilter.py +++ b/Tribler/Core/Category/FamilyFilter.py @@ -83,8 +83,7 @@ def isXXX(self, s, isFilename=True, nonXXXFormat=False): num_xxx = len([w for w in words + words2 if self.isXXXTerm(w, s)]) if nonXXXFormat or (isFilename and self.isAudio(s)): return num_xxx > 2 # almost never classify mp3 as porn - else: - return num_xxx > 0 + return num_xxx > 0 def foundXXXTerm(self, s): for term in self.xxx_searchterms: diff --git a/Tribler/Core/Category/category.conf b/Tribler/Core/Category/category.conf index ec1bb71508d..143366977ba 100644 --- a/Tribler/Core/Category/category.conf +++ b/Tribler/Core/Category/category.conf @@ -1,23 +1,3 @@ -[xxx] -rank = 10 -displayname = XXX -matchpercentage = 0.001 -strength = 1.1 -# Keywords are in seperate file: filter_content.filter - - -[other] -rank = 8 -displayname = Other -matchpercentage = 0.0 - - -[unknown] -rank = 9 -displayname = Unknown -matchpercentage = 0.0 - - [Video] rank = 1 displayname = Video Files @@ -45,13 +25,13 @@ suffix = cda, flac, m3u, mp2, mp3, vorbis, wav, wma, ogg, ape matchpercentage = 0.8 [Document] -rank = 5 +rank = 4 displayname = Documents -suffix = doc, pdf, ppt, ps, tex, txt, vsd +suffix = doc, pdf, ppt, ps, tex, txt, vsd, xls matchpercentage = 0.8 [Compressed] -rank = 4 +rank = 5 displayname = Compressed suffix = ace, bin, bwt, cab, gzip, jar, rar, tar, z, zip matchpercentage = 0.8 @@ -69,12 +49,31 @@ matchpercentage = 0.8 [CD/DVD/BD] rank = 6 -displayname = Compressed +displayname = CD/DVD/BD suffix = iso, mdf, mds, nrg, vcd, dmg, toast, cue, dvd, imd, md0, md1, md2, mdx, udf, wii, dmgpart, cso, ccd, cdi, dax, xvd, sub, daa, vc4, lcd matchpercentage = 0.8 [Picture] -rank = 6 +rank = 7 displayname = Pictures suffix = bmp, dib, dwg, gif, ico, jpeg, jpg, pic, png, swf, tif, tiff matchpercentage = 0.8 + +[other] +rank = 8 +displayname = Other +matchpercentage = 0.0 + +[unknown] +rank = 9 +displayname = Unknown +matchpercentage = 0.0 + +[xxx] +rank = 10 +displayname = XXX +matchpercentage = 0.001 +strength = 1.1 +# Keywords are in seperate file: filter_content.filter + + diff --git a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py index d0db731b28d..b382d35eba0 100644 --- a/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py +++ b/Tribler/Core/Libtorrent/LibtorrentDownloadImpl.py @@ -62,9 +62,10 @@ def read(self, *args): self._logger.debug('VODFile: get bytes %s - %s', oldpos, oldpos + args[0]) - while not self._file.closed and self._download.get_byte_progress([(self._download.get_vod_fileindex(), oldpos, - oldpos + args[ - 0])]) < 1 and self._download.vod_seekpos is not None: + while not self._file.closed \ + and self._download.get_byte_progress( + [(self._download.get_vod_fileindex(), oldpos, oldpos + args[0])]) < 1 \ + and self._download.vod_seekpos is not None: time.sleep(1) if self._file.closed: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py deleted file mode 100644 index b2fa51a4657..00000000000 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/author.py +++ /dev/null @@ -1,11 +0,0 @@ -from pony import orm - -from Tribler.pyipv8.ipv8.database import database_blob - - -def define_binding(db): - class Author(db.Entity): - public_key = orm.PrimaryKey(database_blob) - authored = orm.Set('ChannelNode') - - return Author diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index a236faa1cd1..4d010d5eea2 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -193,7 +193,7 @@ def update_channel_torrent(self, metadata_list): torrent_date = datetime.utcfromtimestamp(torrent['creation date']) return {"infohash": infohash, "num_entries": self.contents_len, - "timestamp": new_timestamp, "torrent_date": torrent_date} + "timestamp": new_timestamp, "torrent_date": torrent_date}, torrent def commit_channel_torrent(self, new_start_timestamp=None): """ @@ -202,12 +202,13 @@ def commit_channel_torrent(self, new_start_timestamp=None): :return The new infohash, should be used to update the downloads """ new_infohash = None + torrent = None md_list = self.staged_entries_list if not md_list: return None try: - update_dict = self.update_channel_torrent(md_list) + update_dict, torrent = self.update_channel_torrent(md_list) except IOError: self._logger.error( "Error during channel torrent commit, not going to garbage collect the channel. Channel %s", @@ -230,7 +231,7 @@ def commit_channel_torrent(self, new_start_timestamp=None): self._logger.info("Channel %s committed with %i new entries. New version is %i", str(self.public_key).encode("hex"), len(md_list), update_dict['timestamp']) - return new_infohash + return torrent @db_session def get_torrent(self, infohash): @@ -412,7 +413,7 @@ def get_random_channels(cls, limit, only_subscribed=False): :rtype: list """ if only_subscribed: - select_lambda = lambda g: g.subscribed == True and g.status != LEGACY_ENTRY and g.num_entries > 0 + select_lambda = lambda g: g.subscribed and g.status != LEGACY_ENTRY and g.num_entries > 0 else: select_lambda = lambda g: g.status != LEGACY_ENTRY and g.num_entries > 0 @@ -433,8 +434,7 @@ def get_updated_channels(cls): @classmethod @db_session - def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, subscribed=False, - hide_xxx=False): + def get_channels(cls, first=1, last=50, subscribed=False, hide_xxx=False, **kwargs): """ Get some channels. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. @@ -442,8 +442,7 @@ def get_channels(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filte the total number of results, regardless the passed first/last parameter. """ # TODO: rewrite this with **kwargs expansion - pony_query = ChannelMetadata.get_entries_query(sort_by=sort_by, sort_asc=sort_asc, - query_filter=query_filter) + pony_query = ChannelMetadata.get_entries_query(**kwargs) # Filter subscribed/non-subscribed if subscribed: diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index 57615fad623..5c832c3ee02 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -40,7 +40,7 @@ def generate_dict_from_pony_args(cls, skip_list=None, **kwargs): return d -def define_binding(db): +def define_binding(db, logger=None, key=None, clock=None): class ChannelNode(db.Entity): _discriminator_ = CHANNEL_NODE @@ -67,9 +67,9 @@ class ChannelNode(db.Entity): # Special properties _payload_class = ChannelNodePayload - _my_key = None - _logger = None - _clock = None + _my_key = key + _logger = logger + _clock = clock def __init__(self, *args, **kwargs): """ diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index af9da55ba56..61d26c22b15 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -103,16 +103,14 @@ def get_random_torrents(cls, limit): @classmethod @db_session - def get_torrents(cls, first=1, last=50, sort_by=None, sort_asc=True, query_filter=None, channel_pk=False, - exclude_deleted=False, hide_xxx=False): + def get_torrents(cls, first=1, last=50, channel_pk=False, exclude_deleted=False, hide_xxx=False, **kwargs): """ Get some torrents. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - pony_query = TorrentMetadata.get_entries_query( - sort_by=sort_by, sort_asc=sort_asc, query_filter=query_filter) + pony_query = TorrentMetadata.get_entries_query(**kwargs) # We only want torrents, not channel torrents pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index 0323eb6d77a..e7e590e4701 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -12,7 +12,7 @@ torrent_state, tracker_state, channel_node, misc from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import BLOB_EXTENSION from Tribler.Core.Modules.MetadataStore.serialization import read_payload_with_offset, REGULAR_TORRENT, \ - CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, int2time, time2int + CHANNEL_TORRENT, DELETED, ChannelMetadataPayload, time2int # This table should never be used from ORM directly. # It is created as a VIRTUAL table by raw SQL and # maintained by SQL triggers. @@ -100,22 +100,15 @@ def __init__(self, db_filename, channels_dir, my_key): # at definition. self._db = orm.Database() - # Accessors for ORM-managed classes - # self.Author = author.define_binding(self._db) - self.MiscData = misc.define_binding(self._db) self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) - self.ChannelNode = channel_node.define_binding(self._db) + self.ChannelNode = channel_node.define_binding(self._db, logger=self._logger, key=my_key, clock=self.clock) self.TorrentMetadata = torrent_metadata.define_binding(self._db) self.ChannelMetadata = channel_metadata.define_binding(self._db) - self.ChannelNode._logger = self._logger # Use Store-level logger for every ORM-based class - self.ChannelNode._my_key = my_key - self.ChannelNode._clock = self.clock - self.ChannelMetadata._channels_dir = channels_dir self._db.bind(provider='sqlite', filename=db_filename, create_db=create_db) @@ -194,11 +187,8 @@ def process_mdblob_file(self, filepath): with open(filepath, 'rb') as f: serialized_data = f.read() - if filepath.endswith('.lz4'): - return self.process_compressed_mdblob(serialized_data) - else: - return self.process_squashed_mdblob(serialized_data) - + return (self.process_compressed_mdblob(serialized_data) if filepath.endswith('.lz4') else + self.process_squashed_mdblob(serialized_data)) @db_session def process_compressed_mdblob(self, compressed_data): return self.process_squashed_mdblob(lz4.frame.decompress(compressed_data)) diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index f999188790d..c275dd583b3 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -28,7 +28,17 @@ def start(self): The Metadata Store checks the database at regular intervals to see if new channels are available for preview or subscribed channels require updating. """ - self.updated_my_channel() # Just in case + + # Test if we our channel is there, but we don't share it because Tribler was closed unexpectedly + try: + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() + if my_channel and my_channel.status == COMMITTED and \ + not self.session.has_download(str(my_channel.infohash)): + torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") + self.updated_my_channel(TorrentDef.load(torrent_path)) + except: + pass channels_check_interval = 5.0 # seconds lcall = LoopingCall(self.service_channels) @@ -63,7 +73,7 @@ def remove_cruft_channels(self): # TODO: add some more advanced logic for removal of older channel versions cruft_list = [(d, d.get_def().get_name_utf8() not in dirnames) \ for d in self.session.lm.get_channel_downloads() \ - if (bytes(d.get_def().infohash) not in subscribed_infohashes)] + if bytes(d.get_def().infohash) not in subscribed_infohashes] self.remove_channels_downloads(cruft_list) def service_channels(self): @@ -109,24 +119,9 @@ def on_channel_download_finished(self, download, channel_id, finished_deferred=N if finished_deferred: finished_deferred.callback(download) - @db_session - def remove_channel(self, channel): - """ - Remove a channel from your local database/download list. - :param channel: The channel to remove. - """ - channel.subscribed = False - channel.remove_contents() - channel.local_version = 0 - - # Remove all stuff matching the channel dir name / public key / torrent title - remove_list = [(d, True) for d in self.session.lm.get_channel_downloads() if - d.tdef.get_name_utf8() == channel.dir_name] - self.remove_channels_downloads(remove_list) - # TODO: finish this routine # This thing should check if the files in the torrent we're going to delete are used in another torrent for - # a newer version of the same channel, and determine a safe sub-set to delete. + # the newer version of the same channel, and determine a safe sub-set to delete. """ def safe_files_to_remove(self, download): # Check for intersection of files from old download with files from the newer version of the same channel @@ -150,6 +145,8 @@ def remove_channels_downloads(self, to_remove_list): :param to_remove_list: list of tuples (download_to_remove=download, remove_files=Bool) """ + #TODO: make file removal from older versions safe (i.e. check if it overlaps with newer downloads) + """ files_to_remove = [] for download in to_remove_list: @@ -196,27 +193,14 @@ def download_channel(self, channel): download.finished_callback(download) return download, finished_deferred - def updated_my_channel(self): + def updated_my_channel(self, tdef): """ Notify the core that we updated our channel. """ - try: + with db_session: my_channel = self.session.lm.mds.ChannelMetadata.get_my_channel() - if my_channel and my_channel.status == COMMITTED and \ - not self.session.has_download(str(my_channel.infohash)): - torrent_path = os.path.join(self.session.lm.mds.channels_dir, my_channel.dir_name + ".torrent") - else: - return - - tdef = TorrentDef.load(torrent_path) + if my_channel and my_channel.status == COMMITTED and not self.session.has_download(str(my_channel.infohash)): dcfg = DownloadStartupConfig() dcfg.set_dest_dir(self.session.lm.mds.channels_dir) dcfg.set_channel_download(True) self.session.lm.add(tdef, dcfg) - except: - # Ugly recursive workaround for race condition when the torrent file is not there - # FIXME: stop using intermediary torrent file for personal channel - taskname = "updated_my_channel delayed attempt" - if not self.is_pending_task_active(taskname): - d = task.deferLater(reactor, 7, self.updated_my_channel) - self.register_task(taskname, d) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 8e494911cfa..c3276baf134 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -19,18 +19,17 @@ def sanitize_parameters(parameters): """ Sanitize the parameters for a request that fetches channels. """ - first = 1 if 'first' not in parameters else int(parameters['first'][0]) - last = 50 if 'last' not in parameters else int(parameters['last'][0]) - sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] - sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) - query_filter = None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]) - hide_xxx = False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0) - - if sort_by: - sort_by = MetadataEndpoint.convert_sort_param_to_pony_col(sort_by) + sanitized = { + "first": 1 if 'first' not in parameters else int(parameters['first'][0]), + "last": 50 if 'last' not in parameters else int(parameters['last'][0]), + "sort_by": None if 'sort_by' not in parameters else MetadataEndpoint.convert_sort_param_to_pony_col( + parameters['sort_by'][0]), + "sort_asc": True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])), + "query_filter": None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]), + "hide_xxx": False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0)} # TODO: stop using tuples, do proper dict - return first, last, sort_by, sort_asc, query_filter, hide_xxx + return sanitized class MetadataEndpoint(BaseMetadataEndpoint): @@ -80,13 +79,12 @@ def sanitize_parameters(parameters): """ Sanitize the parameters for a request that fetches channels. """ - first, last, sort_by, sort_asc, query_filter, hide_xxx = BaseMetadataEndpoint.sanitize_parameters(parameters) + sanitized = BaseMetadataEndpoint.sanitize_parameters(parameters) - subscribed = False if 'subscribed' in parameters: - subscribed = bool(int(parameters['subscribed'][0])) + sanitized['subscribed'] = bool(int(parameters['subscribed'][0])) - return first, last, sort_by, sort_asc, query_filter, hide_xxx, subscribed + return sanitized class ChannelsEndpoint(BaseChannelsEndpoint): @@ -98,19 +96,17 @@ def getChild(self, path, request): return SpecificChannelEndpoint(self.session, path) def render_GET(self, request): - first, last, sort_by, sort_asc, query_filter, hide_xxx, subscribed = ChannelsEndpoint.sanitize_parameters( - request.args) - channels, total = self.session.lm.mds.ChannelMetadata.get_channels( - first, last, sort_by, sort_asc, query_filter, subscribed, hide_xxx=hide_xxx) + sanitized = ChannelsEndpoint.sanitize_parameters(request.args) + channels, total = self.session.lm.mds.ChannelMetadata.get_channels(**sanitized) channels = [channel.to_simple_dict() for channel in channels] return json.dumps({ "channels": channels, - "first": first, - "last": last, - "sort_by": sort_by, - "sort_asc": int(sort_asc), + "first": sanitized["first"], + "last": sanitized["last"], + "sort_by": sanitized["sort_by"], + "sort_asc": int(sanitized["sort_asc"]), "total": total }) @@ -169,13 +165,10 @@ def sanitize_parameters(parameters): """ Sanitize the parameters for a request that fetches channels. """ - first, last, sort_by, sort_asc, query_filter, hide_xxx = BaseMetadataEndpoint.sanitize_parameters(parameters) - - channel = '' + sanitized = BaseMetadataEndpoint.sanitize_parameters(parameters) if 'channel' in parameters: - channel = unhexlify(parameters['channel'][0]) - - return first, last, sort_by, sort_asc, query_filter, hide_xxx, channel + sanitized['channel'] = unhexlify(parameters['channel'][0]) + return sanitized class SpecificChannelTorrentsEndpoint(BaseTorrentsEndpoint): @@ -185,20 +178,17 @@ def __init__(self, session, channel_pk): self.channel_pk = channel_pk def render_GET(self, request): - first, last, sort_by, sort_asc, query_filter, hide_xxx, _ = \ - SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) + sanitized = SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) with db_session: - torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, self.channel_pk, hide_xxx=hide_xxx) - + torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents(channel_pk=self.channel_pk, **sanitized) torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ "torrents": torrents, - "first": first, - "last": last, - "sort_by": sort_by, - "sort_asc": int(sort_asc), + "first": sanitized['first'], + "last": sanitized['last'], + "sort_by": sanitized['sort_by'], + "sort_asc": int(sanitized['sort_asc']), "total": total }) diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index c3b393532f9..4d2316c6a42 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -109,21 +109,20 @@ def render_GET(self, request): request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": "your channel has not been created"}) - request.args['channel'] = [str(my_channel.public_key).encode('hex')] - first, last, sort_by, sort_asc, query_filter, hide_xxx, channel = \ - SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) - exclude_deleted = 'exclude_deleted' in request.args and request.args['exclude_deleted'] + request.args['channel_pk'] = [str(my_channel.public_key).encode('hex')] + sanitized = SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) + if 'exclude_deleted' in request.args: + sanitized['exclude_deleted'] = request.args['exclude_deleted'] - torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents( - first, last, sort_by, sort_asc, query_filter, channel, exclude_deleted=exclude_deleted, hide_xxx=hide_xxx) + torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents(**sanitized) torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ "torrents": torrents, - "first": first, - "last": last, - "sort_by": sort_by, - "sort_asc": int(sort_asc), + "first": sanitized['first'], + "last": sanitized['last'], + "sort_by": sanitized['sort_by'], + "sort_asc": int(sanitized['sort_asc']), "total": total, "dirty": my_channel.dirty }) @@ -327,7 +326,8 @@ def render_POST(self, request): request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": "your channel has not been created"}) - if my_channel.commit_channel_torrent(): - self.session.lm.gigachannel_manager.updated_my_channel() + torrent_dict = my_channel.commit_channel_torrent() + if torrent_dict: + self.session.lm.gigachannel_manager.updated_my_channel(TorrentDef(metainfo=torrent_dict)) return json.dumps({"success": True}) diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index 904ac412180..a395c060ac7 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -174,29 +174,29 @@ def update_tracker_info(self, tracker_url, value): def get_valid_trackers_of_torrent(self, torrent_id): """ Get a set of valid trackers for torrent. Also remove any invalid torrent.""" db_tracker_list = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(torrent_id)).trackers - return set([str(tracker.url) for tracker in db_tracker_list if - is_valid_url(str(tracker.url)) or str(tracker.url) == u'DHT']) + return set([str(tracker.url) for tracker in db_tracker_list if is_valid_url(str(tracker.url))]) def on_gui_request_completed(self, infohash, result): final_response = {} torrent_update_dict = {'infohash': infohash, 'seeders': 0, 'leechers': 0, 'last_check': int(time.time())} - for success, response in result: + for success, response in reversed(result): if not success and isinstance(response, Failure): final_response[response.tracker_url] = {'error': response.getErrorMessage()} continue + final_response[response.keys()[0]] = response[response.keys()[0]][0] - response_seeders = response[response.keys()[0]][0]['seeders'] - response_leechers = response[response.keys()[0]][0]['leechers'] - if response_seeders > torrent_update_dict['seeders'] or \ - (response_seeders == torrent_update_dict['seeders'] - and response_leechers < torrent_update_dict['leechers']): - torrent_update_dict['seeders'] = response_seeders - torrent_update_dict['leechers'] = response_leechers + s = response[response.keys()[0]][0]['seeders'] + l = response[response.keys()[0]][0]['leechers'] - final_response[response.keys()[0]] = response[response.keys()[0]][0] + # Less leeches is better, except for the zero seeds case. I.e. s0 l2 > s0 l1 + if s > torrent_update_dict['seeders'] or \ + (s == torrent_update_dict['seeders'] and l < torrent_update_dict['leechers']) or \ + (s == 0 and torrent_update_dict['seeders'] == 0 and l > torrent_update_dict['leechers']): + torrent_update_dict['seeders'] = s + torrent_update_dict['leechers'] = l - self._update_torrent_result(torrent_update_dict, final_response) + self._update_torrent_result(torrent_update_dict) # Add this result to popularity community to publish to subscribers self.publish_torrent_result(torrent_update_dict) @@ -291,7 +291,7 @@ def _on_result_from_session(self, session, result_list): return result_list - def _update_torrent_result(self, response, update_dict): + def _update_torrent_result(self, response): infohash = response['infohash'] seeders = response['seeders'] leechers = response['leechers'] @@ -301,14 +301,11 @@ def _update_torrent_result(self, response, update_dict): self._logger.debug(u"Update result %s/%s for %s", seeders, leechers, hexlify(infohash)) with db_session: + # Update torrent state result = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(infohash)) - for tracker in result.trackers: - tracker.last_check = int(time.time()) - if update_dict.get(tracker.url, {'seeders': 0, 'leechers': 0}) > 0: - tracker.alive = True - tracker.failures = 0 - else: - tracker.failures = min(tracker.failures + 1, self._max_torrent_check_retries) + if not result: + # Something is wrong, there should exist a corresponding TorrentState entry in the DB. + return result.seeders = seeders result.leechers = leechers result.last_check = last_check diff --git a/Tribler/Core/Utilities/tracker_utils.py b/Tribler/Core/Utilities/tracker_utils.py index 64c4afdd1d6..da12b9c7e81 100644 --- a/Tribler/Core/Utilities/tracker_utils.py +++ b/Tribler/Core/Utilities/tracker_utils.py @@ -99,7 +99,7 @@ def get_uniformed_tracker_url(tracker_url): uniformed_url = u'%s://%s%s' % (uniformed_scheme, uniformed_hostname, uniformed_path) else: uniformed_url = u'%s://%s:%d%s' % (uniformed_scheme, uniformed_hostname, uniformed_port, uniformed_path) - except (UnicodeError, ValueError): + except ValueError: continue else: return uniformed_url diff --git a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py index 8a837566290..1137e788f34 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_mychannel_endpoint.py @@ -19,7 +19,7 @@ def setUp(self): yield super(BaseTestMyChannelEndpoint, self).setUp() self.session.lm.gigachannel_manager = MockObject() self.session.lm.gigachannel_manager.shutdown = lambda: None - self.session.lm.gigachannel_manager.updated_my_channel = lambda: None + self.session.lm.gigachannel_manager.updated_my_channel = lambda _: None def create_my_channel(self): with db_session: diff --git a/Tribler/Test/Core/Modules/test_gigachannel_manager.py b/Tribler/Test/Core/Modules/test_gigachannel_manager.py index 5571a19a07b..ffbbe5b77bd 100644 --- a/Tribler/Test/Core/Modules/test_gigachannel_manager.py +++ b/Tribler/Test/Core/Modules/test_gigachannel_manager.py @@ -2,7 +2,7 @@ from datetime import datetime from pony.orm import db_session -from twisted.internet.defer import inlineCallbacks +from twisted.internet.defer import inlineCallbacks, Deferred from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.store import MetadataStore @@ -123,11 +123,16 @@ class mock_dl(MockObject): def __init__(self, infohash, dirname): self.infohash = infohash self.dirname = dirname + self.tdef = MockObject() + self.tdef.get_name_utf8 = lambda : self.dirname + self.tdef.get_infohash = lambda : infohash + def get_def(self): a = MockObject() a.infohash = self.infohash a.get_name_utf8 = lambda: self.dirname + a.get_infohash = lambda : self.infohash return a # Double conversion is required to make sure that buffers signatures are not the same @@ -151,10 +156,14 @@ def mock_get_channel_downloads(): self.remove_list = [] - def mock_remove_channels_downloads(remove_list): - self.remove_list = remove_list + def mock_remove_download(infohash, remove_content=False): + d = Deferred() + d.callback(None) + self.remove_list.append((infohash,remove_content)) + return d + + self.chanman.session.remove_download = mock_remove_download - self.chanman.remove_channels_downloads = mock_remove_channels_downloads self.mock_session.lm.get_channel_downloads = mock_get_channel_downloads self.chanman.remove_cruft_channels() # We want to remove torrents for (a) deleted channels and (b) unsubscribed channels @@ -164,3 +173,7 @@ def mock_remove_channels_downloads(remove_list): (mock_dl_list[4], True), (mock_dl_list[5], True), (mock_dl_list[6], True)]) + + + + diff --git a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py index c63642076e1..23b18ea43fa 100644 --- a/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py +++ b/Tribler/Test/Core/TorrentChecker/test_torrentchecker.py @@ -2,10 +2,11 @@ import socket import time +from binascii import hexlify from pony.orm import db_session - from twisted.internet.defer import Deferred, inlineCallbacks +from twisted.python.failure import Failure from Tribler.Core.Modules.tracker_manager import TrackerManager from Tribler.Core.TorrentChecker.session import HttpTrackerSession, UdpSocketManager @@ -40,7 +41,12 @@ def get_metainfo(infohash, callback, **_): self.session.lm.ltmgr = MockObject() self.session.lm.ltmgr.get_metainfo = get_metainfo - self.session.lm.ltmgr.shutdown = lambda : None + self.session.lm.ltmgr.shutdown = lambda: None + + @inlineCallbacks + def tearDown(self): + yield self.torrent_checker.shutdown() + yield super(TestTorrentChecker, self).tearDown() def test_initialize(self): """ @@ -53,6 +59,7 @@ def test_create_socket_or_schedule_fail(self): """ Test creation of the UDP socket of the torrent checker when it fails """ + def mocked_listen_on_udp(): raise socket.error("Something went wrong") @@ -127,6 +134,7 @@ def test_tracker_test_error_resolve(self): """ Test whether we capture the error when a tracker check fails """ + def verify_cleanup(_): # Verify whether we successfully cleaned up the session after an error self.assertEqual(len(self.torrent_checker._session_list), 1) @@ -183,7 +191,7 @@ def popularity_community_queue_content(torrent_checker, _): popularity_community_queue_content(self.torrent_checker, _content) # Case1: Fake torrent checker response, seeders:0 - fake_response = {'infohash': 'a'*20, 'seeders': 0, 'leechers': 0, 'last_check': time.time()} + fake_response = {'infohash': 'a' * 20, 'seeders': 0, 'leechers': 0, 'last_check': time.time()} self.torrent_checker.publish_torrent_result(fake_response) self.assertTrue(self.torrent_checker.zero_seed_torrent) @@ -202,7 +210,30 @@ def popularity_community_queue_content(torrent_checker, _): self.torrent_checker._logger.info = original_logger_info - @inlineCallbacks - def tearDown(self): - yield self.torrent_checker.shutdown() - yield super(TestTorrentChecker, self).tearDown() + def test_on_gui_request_completed(self): + tracker1 = 'udp://localhost:2801' + tracker2 = "http://badtracker.org/announce" + infohash_bin = '\xee'*20 + infohash_hex = hexlify(infohash_bin) + self.session.lm.popularity_community.queue_content = lambda _: None + + failure = Failure() + failure.tracker_url = tracker2 + result = [ + (True, {tracker1: [{'leechers': 1, 'seeders': 2, 'infohash': infohash_hex}]}), + (False, failure), + (True, {'DHT': [{'leechers': 12, 'seeders': 13, 'infohash': infohash_hex}]}) + ] + # Check that everything works fine even if the database contains no proper infohash + self.torrent_checker.on_gui_request_completed(infohash_bin, result) + self.assertDictEqual(self.torrent_checker.on_gui_request_completed(infohash_bin, result), + {'DHT': {'leechers': 12, 'seeders': 13, 'infohash': infohash_hex}, + 'http://badtracker.org/announce': {'error': ''}, 'udp://localhost:2801': {'leechers': 1, 'seeders': 2, 'infohash': infohash_hex}}) + + with db_session: + ts = self.session.lm.mds.TorrentState(infohash=infohash_bin) + previous_check = ts.last_check + self.torrent_checker.on_gui_request_completed(infohash_bin, result) + self.assertEqual(result[2][1]['DHT'][0]['leechers'], ts.leechers) + self.assertEqual(result[2][1]['DHT'][0]['seeders'], ts.seeders) + self.assertLess(previous_check, ts.last_check) diff --git a/Tribler/Test/Core/Upgrade/test_db72_to_pony.py b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py index 0b0b4caf512..fae253972f2 100644 --- a/Tribler/Test/Core/Upgrade/test_db72_to_pony.py +++ b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py @@ -55,9 +55,11 @@ def test_convert_personal_channel(self): def test_convert_all_channels(self): self.m.initialize() + self.m.convert_discovered_torrents() self.m.convert_discovered_channels() chans = self.mds.ChannelMetadata.get_channels() - self.assertEqual(len(chans), 2) + + self.assertEqual(len(chans[0]), 2) for c in chans[0]: self.assertNotEqual(self.m.personal_channel_title[:200], c.title) self.assertEqual(c.status, LEGACY_ENTRY) diff --git a/Tribler/Test/Core/Utilities/test_tracker_utils.py b/Tribler/Test/Core/Utilities/test_tracker_utils.py index 32569359ce1..fdffeaea5f7 100644 --- a/Tribler/Test/Core/Utilities/test_tracker_utils.py +++ b/Tribler/Test/Core/Utilities/test_tracker_utils.py @@ -66,6 +66,18 @@ def test_uniform_empty(self): result = get_uniformed_tracker_url(u'') self.assertIsNone(result) + def test_skip_truncated_url(self): + result = get_uniformed_tracker_url(u'http://tracker.1337x.org:80/anno...') + self.assertIsNone(result) + + def test_skip_wrong_url_scheme(self): + result = get_uniformed_tracker_url(u'ftp://tracker.1337x.org:80/announce') + self.assertIsNone(result) + + def test_skip_value_error(self): + result = get_uniformed_tracker_url("ftp://tracker.1337\xffx.org:80/announce") + self.assertIsNone(result) + class TestParseTrackerUrl(TriblerCoreTest): From 88d036b628beafc80935530a32f08a5c3e6263a4 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Thu, 14 Feb 2019 17:20:27 +0100 Subject: [PATCH 32/38] Move DicreteClock to use db --- .../OrmBindings/channel_metadata.py | 1 - Tribler/Core/Modules/MetadataStore/store.py | 32 +++++++---- .../Modules/restapi/downloads_endpoint.py | 33 +++++++----- .../Core/Modules/restapi/events_endpoint.py | 1 - .../Core/Modules/restapi/metadata_endpoint.py | 1 - Tribler/Core/Upgrade/db72_to_pony.py | 2 +- .../Community/popularity/test_community.py | 1 + .../Community/popularity/test_repository.py | 2 +- .../MetadataStore/test_channel_metadata.py | 3 +- .../Modules/MetadataStore/test_metadata.py | 9 +++- .../Core/Modules/MetadataStore/test_store.py | 54 ++++++++++++++++--- .../MetadataStore/test_torrent_metadata.py | 18 +++++-- .../MetadataStore/test_tracker_state.py | 3 +- .../Core/Modules/test_gigachannel_manager.py | 3 +- .../Test/Core/Utilities/test_tracker_utils.py | 2 +- 15 files changed, 115 insertions(+), 50 deletions(-) diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 4d010d5eea2..39a20b3dfdb 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -441,7 +441,6 @@ def get_channels(cls, first=1, last=50, subscribed=False, hide_xxx=False, **kwar :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - # TODO: rewrite this with **kwargs expansion pony_query = ChannelMetadata.get_entries_query(**kwargs) # Filter subscribed/non-subscribed diff --git a/Tribler/Core/Modules/MetadataStore/store.py b/Tribler/Core/Modules/MetadataStore/store.py index e7e590e4701..0ae4f822711 100644 --- a/Tribler/Core/Modules/MetadataStore/store.py +++ b/Tribler/Core/Modules/MetadataStore/store.py @@ -65,23 +65,30 @@ class BadChunkException(Exception): class DiscreteClock(object): # Lamport-clock-like persistent counter # Horribly inefficient and stupid, but works - def __init__(self, filename=None): - self.filename = filename + store_value_name = "discrete_clock" + + def __init__(self, datastore=None): # This is a stupid workaround for people who reinstall Tribler # and lose their database. We don't know what was their channel # clock before, but at least we can assume that they were not # adding to it 1000 torrents per second constantly... self.clock = time2int(datetime.utcnow()) * 1000 - # Read the clock from the disk if the filename is given - if self.filename and os.path.isfile(self.filename): - with open(self.filename, 'rb') as f: - self.clock = int(f.read()) + self.datastore = datastore + + def init_clock(self): + if self.datastore: + with db_session: + store_object = self.datastore.get(name=self.store_value_name, ) + if not store_object: + self.datastore(name=self.store_value_name, value=str(self.clock)) + else: + self.clock = int(store_object.value) def tick(self): self.clock += 1 - if self.filename: - with open(self.filename, 'wb') as f: - f.write(str(self.clock)) + if self.datastore: + with db_session: + self.datastore[self.store_value_name].value = str(self.clock) return self.clock @@ -91,7 +98,6 @@ def __init__(self, db_filename, channels_dir, my_key): self.channels_dir = channels_dir self.my_key = my_key self._logger = logging.getLogger(self.__class__.__name__) - self.clock = DiscreteClock(None if db_filename == ":memory:" else os.path.join(channels_dir, CLOCK_STATE_FILE)) create_db = (db_filename == ":memory:" or not os.path.isfile(self.db_filename)) @@ -105,6 +111,8 @@ def __init__(self, db_filename, channels_dir, my_key): self.TrackerState = tracker_state.define_binding(self._db) self.TorrentState = torrent_state.define_binding(self._db) + self.clock = DiscreteClock(None if db_filename == ":memory:" else self.MiscData) + self.ChannelNode = channel_node.define_binding(self._db, logger=self._logger, key=my_key, clock=self.clock) self.TorrentMetadata = torrent_metadata.define_binding(self._db) self.ChannelMetadata = channel_metadata.define_binding(self._db) @@ -129,6 +137,8 @@ def __init__(self, db_filename, channels_dir, my_key): with db_session: self.MiscData(name="db_version", value="0") + self.clock.init_clock() + def shutdown(self): self._db.disconnect() @@ -222,6 +232,8 @@ def process_payload(self, payload): elif payload.metadata_type == CHANNEL_TORRENT: return self.update_channel_info(payload) + return None, NO_ACTION + @db_session def update_channel_info(self, payload): """ diff --git a/Tribler/Core/Modules/restapi/downloads_endpoint.py b/Tribler/Core/Modules/restapi/downloads_endpoint.py index 059ee993235..57b5234ebb1 100644 --- a/Tribler/Core/Modules/restapi/downloads_endpoint.py +++ b/Tribler/Core/Modules/restapi/downloads_endpoint.py @@ -1,16 +1,13 @@ from __future__ import absolute_import import logging - from libtorrent import bencode, create_torrent -from pony.orm import db_session - import six +from pony.orm import db_session from six import unichr # pylint: disable=redefined-builtin from six.moves.urllib.parse import unquote_plus from six.moves.urllib.request import url2pathname - from twisted.web import http, resource from twisted.web.server import NOT_DONE_YET @@ -245,25 +242,33 @@ def get_chant_name(name, infohash): else: return u'OLD:' + channel.title - download_json = {"name": get_chant_name(tdef.get_name_utf8(), - tdef.get_infohash()) if download.get_channel_download() else tdef.get_name_utf8(), + download_json = {"name": get_chant_name(tdef.get_name_utf8(), tdef.get_infohash()) + if download.get_channel_download() else tdef.get_name_utf8(), "progress": state.get_progress(), "infohash": tdef.get_infohash().encode('hex'), "speed_down": state.get_current_payload_speed(DOWNLOAD), "speed_up": state.get_current_payload_speed(UPLOAD), "status": dlstatus_strings[state.get_status()], - "size": tdef.get_length(), "eta": state.get_eta(), - "num_peers": num_peers, "num_seeds": num_seeds, - "num_connected_peers": num_connected_peers, "num_connected_seeds": num_connected_seeds, + "size": tdef.get_length(), + "eta": state.get_eta(), + "num_peers": num_peers, + "num_seeds": num_seeds, + "num_connected_peers": num_connected_peers, + "num_connected_seeds": num_connected_seeds, "total_up": state.get_total_transferred(UPLOAD), - "total_down": state.get_total_transferred(DOWNLOAD), "ratio": state.get_seeding_ratio(), - "trackers": tracker_info, "hops": download.get_hops(), - "anon_download": download.get_anon_mode(), "safe_seeding": download.get_safe_seeding(), + "total_down": state.get_total_transferred(DOWNLOAD), + "ratio": state.get_seeding_ratio(), + "trackers": tracker_info, + "hops": download.get_hops(), + "anon_download": download.get_anon_mode(), + "safe_seeding": download.get_safe_seeding(), # Maximum upload/download rates are set for entire sessions "max_upload_speed": self.session.config.get_libtorrent_max_upload_rate(), "max_download_speed": self.session.config.get_libtorrent_max_download_rate(), - "destination": download.get_dest_dir(), "availability": state.get_availability(), - "total_pieces": tdef.get_nr_pieces(), "vod_mode": download.get_mode() == DLMODE_VOD, + "destination": download.get_dest_dir(), + "availability": state.get_availability(), + "total_pieces": tdef.get_nr_pieces(), + "vod_mode": download.get_mode() == DLMODE_VOD, "vod_prebuffering_progress": state.get_vod_prebuffering_progress(), "vod_prebuffering_progress_consec": state.get_vod_prebuffering_progress_consec(), "error": repr(state.get_error()) if state.get_error() else "", diff --git a/Tribler/Core/Modules/restapi/events_endpoint.py b/Tribler/Core/Modules/restapi/events_endpoint.py index 9c686168f59..dc7c4178754 100644 --- a/Tribler/Core/Modules/restapi/events_endpoint.py +++ b/Tribler/Core/Modules/restapi/events_endpoint.py @@ -1,5 +1,4 @@ from __future__ import absolute_import -from __future__ import absolute_import import time diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index c3276baf134..fded85bc2cb 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -28,7 +28,6 @@ def sanitize_parameters(parameters): "query_filter": None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]), "hide_xxx": False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0)} - # TODO: stop using tuples, do proper dict return sanitized diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py index e8f891e50d4..ee6dda5b4dd 100644 --- a/Tribler/Core/Upgrade/db72_to_pony.py +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -83,7 +83,7 @@ def get_old_channels(self): "public_key": dispesy_cid_to_pk(id_), "timestamp": final_timestamp(), "votes": int(nr_favorite or 0), - #"xxx": float(nr_spam or 0), + # "xxx": float(nr_spam or 0), "origin_id": 0, "signature": pseudo_signature(), "skip_key_check": True, diff --git a/Tribler/Test/Community/popularity/test_community.py b/Tribler/Test/Community/popularity/test_community.py index ae2b1f8e001..2a7c6e96151 100644 --- a/Tribler/Test/Community/popularity/test_community.py +++ b/Tribler/Test/Community/popularity/test_community.py @@ -41,6 +41,7 @@ def test_content_publishing(self): Tests publishing next available content. :return: """ + def on_torrent_health_response(peer, source_address, data): peer.torrent_health_response_received = True diff --git a/Tribler/Test/Community/popularity/test_repository.py b/Tribler/Test/Community/popularity/test_repository.py index aaccedcc4e2..8892979ba20 100644 --- a/Tribler/Test/Community/popularity/test_repository.py +++ b/Tribler/Test/Community/popularity/test_repository.py @@ -20,7 +20,7 @@ class TestContentRepository(TriblerCoreTest): def setUp(self): yield super(TestContentRepository, self).setUp() self.my_key = default_eccrypto.generate_key(u"curve25519") - mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, self.my_key) + mds = MetadataStore(':memory:', self.session_base_dir, self.my_key) self.content_repository = ContentRepository(mds) # Add some content to the metadata database diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index a151c1f3c53..7db34d802cd 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -38,8 +38,7 @@ def setUp(self): "tags": "video" } self.my_key = default_eccrypto.generate_key(u"curve25519") - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - self.my_key) + self.mds = MetadataStore(":memory:", self.session_base_dir, self.my_key) @inlineCallbacks def tearDown(self): diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py index 2e2aba83d31..018517eeb18 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py @@ -6,6 +6,7 @@ from Tribler.Core.Modules.MetadataStore.serialization import KeysMismatchException, ChannelNodePayload from Tribler.Core.Modules.MetadataStore.store import MetadataStore +from Tribler.Core.exceptions import InvalidSignatureException from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto @@ -20,8 +21,7 @@ class TestMetadata(TriblerCoreTest): def setUp(self): yield super(TestMetadata, self).setUp() self.my_key = default_eccrypto.generate_key(u"curve25519") - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - self.my_key) + self.mds = MetadataStore(':memory:', self.session_base_dir, self.my_key) @inlineCallbacks def tearDown(self): @@ -50,6 +50,11 @@ def test_serialization(self): serialized2 = metadata2.serialized() self.assertEqual(serialized1, serialized2) + serialized3 = serialized2[:-5] + "\xee" * 5 + self.assertRaises(InvalidSignatureException, ChannelNodePayload.from_signed_blob, serialized3) + # Test bypass signature check + ChannelNodePayload.from_signed_blob(serialized3, check_signature=False) + @db_session def test_key_mismatch_exception(self): mismatched_key = default_eccrypto.generate_key(u"curve25519") diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index 2550186a0b9..db45203475a 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -11,8 +11,10 @@ from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import entries_to_chunk, CHANNEL_DIR_NAME_LENGTH from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW from Tribler.Core.Modules.MetadataStore.serialization import (ChannelMetadataPayload, SignedPayload, - UnknownBlobTypeException) -from Tribler.Core.Modules.MetadataStore.store import MetadataStore + UnknownBlobTypeException, ChannelNodePayload, + DeletedMetadataPayload) +from Tribler.Core.Modules.MetadataStore.store import MetadataStore, GOT_SAME_VERSION, NO_ACTION, DELETED_METADATA, \ + UNKNOWN_TORRENT, UNKNOWN_CHANNEL from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto @@ -40,7 +42,7 @@ class TestMetadataStore(TriblerCoreTest): def setUp(self): yield super(TestMetadataStore, self).setUp() my_key = default_eccrypto.generate_key(u"curve25519") - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, my_key) + self.mds = MetadataStore(":memory:", self.session_base_dir, my_key) @inlineCallbacks def tearDown(self): @@ -50,14 +52,14 @@ def tearDown(self): @db_session def test_process_channel_dir_file(self): """ - Test whether we are able to process files in a directory containing torrent metadata + Test whether we are able to process files in a directory containing node metadata """ - test_torrent_metadata = self.mds.TorrentMetadata(title='test') + test_node_metadata = self.mds.TorrentMetadata(title='test') metadata_path = os.path.join(self.session_base_dir, 'metadata.data') - test_torrent_metadata.to_file(metadata_path) + test_node_metadata.to_file(metadata_path) # We delete this TorrentMeta info now, it should be added again to the database when loading it - test_torrent_metadata.delete() + test_node_metadata.delete() loaded_metadata = self.mds.process_mdblob_file(metadata_path) self.assertEqual(loaded_metadata[0][0].title, 'test') @@ -136,7 +138,43 @@ def test_process_channel_dir(self): self.assertEqual(channel.local_version, channel.timestamp) @db_session - def test_get_num_channels_torrents(self): + def test_process_payload(self): + def get_payloads(entity_class): + c = entity_class() + payload = c._payload_class.from_signed_blob(c.serialized()) + deleted_payload = DeletedMetadataPayload.from_signed_blob(c.serialized_delete()) + return c, payload, deleted_payload + + _, node_payload, node_deleted_payload = get_payloads(self.mds.ChannelNode) + + self.assertEqual((None, GOT_SAME_VERSION), self.mds.process_payload(node_payload)) + self.assertEqual((None, DELETED_METADATA), self.mds.process_payload(node_deleted_payload)) + # Do nothing in case it is unknown/abstract payload type, like ChannelNode + self.assertEqual((None, NO_ACTION), self.mds.process_payload(node_payload)) + + # Check if node metadata object is properly created on payload processing + node, node_payload, node_deleted_payload = get_payloads(self.mds.TorrentMetadata) + node_dict = node.to_dict() + node.delete() + result = self.mds.process_payload(node_payload) + self.assertEqual(UNKNOWN_TORRENT, result[1]) + self.assertEqual(node_dict['metadata_type'], result[0].to_dict()['metadata_type']) + + # Check the same for a channel + node, node_payload, node_deleted_payload = get_payloads(self.mds.ChannelMetadata) + node_dict = node.to_dict() + node.delete() + # Check there is no action if the signature on the delete object is unknown + self.assertEqual((None, NO_ACTION), self.mds.process_payload(node_deleted_payload)) + result = self.mds.process_payload(node_payload) + self.assertEqual(UNKNOWN_CHANNEL, result[1]) + self.assertEqual(node_dict['metadata_type'], result[0].to_dict()['metadata_type']) + + + + + @db_session + def test_get_num_channels_nodes(self): self.mds.ChannelMetadata(title='testchan', id_=0) self.mds.ChannelMetadata(title='testchan', id_=123) self.mds.ChannelMetadata(title='testchan', id_=0, public_key=unhexlify('0'*20), signature=unhexlify('0'*64), skip_key_check=True) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 89a8bd8ec52..379cc6fd673 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -9,6 +9,7 @@ from six.moves import xrange from twisted.internet.defer import inlineCallbacks +from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import TODELETE from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Test.Core.base_test import TriblerCoreTest from Tribler.pyipv8.ipv8.keyvault.crypto import default_eccrypto @@ -29,8 +30,7 @@ def setUp(self): "tags": "video" } self.my_key = default_eccrypto.generate_key(u"curve25519") - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - self.my_key) + self.mds = MetadataStore(':memory:', self.session_base_dir, self.my_key) @inlineCallbacks def tearDown(self): @@ -151,6 +151,9 @@ def test_get_autocomplete_terms(self): autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms("shee", 10) self.assertIn('sheepish', autocomplete_terms) + autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms("", 10) + self.assertEqual([], autocomplete_terms) + @db_session def test_get_autocomplete_terms_max(self): """ @@ -165,6 +168,7 @@ def test_get_autocomplete_terms_max(self): autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms("sheep", 2) self.assertEqual(len(autocomplete_terms), 2) + # Check that we can chew the special character "." autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms(".", 2) @db_session @@ -174,11 +178,13 @@ def test_get_torrents(self): """ # First we create a few channels and add some torrents to these channels + tlist = [] for ind in xrange(5): self.mds.ChannelNode._my_key = default_eccrypto.generate_key('curve25519') _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) - for torrent_ind in xrange(5): - _ = self.mds.TorrentMetadata(title='torrent%d' % torrent_ind) + tlist.extend([self.mds.TorrentMetadata(title='torrent%d' % torrent_ind) for torrent_ind in xrange(5)]) + tlist[-1].xxx = 1 + tlist[-2].status = TODELETE torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=5) self.assertEqual(len(torrents[0]), 5) @@ -190,6 +196,10 @@ def test_get_torrents(self): self.assertEqual(len(torrents[0]), 5) self.assertEqual(torrents[1], 5) + torrents = self.mds.ChannelMetadata.get_torrents(channel_pk=channel_pk, hide_xxx=True, exclude_deleted=True) + self.assertListEqual(torrents[0][:], tlist[-5:-2]) + self.assertEqual(torrents[1], 3) + @db_session def test_metadata_conflicting(self): tdict = dict(self.torrent_template, title="lakes sheep", tags="video", infohash='\x00\xff') diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py b/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py index 7c87a00b395..d051849cb5f 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_tracker_state.py @@ -19,8 +19,7 @@ class TestTrackerState(TriblerCoreTest): def setUp(self): yield super(TestTrackerState, self).setUp() self.my_key = default_eccrypto.generate_key(u"curve25519") - self.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - self.my_key) + self.mds = MetadataStore(":memory:", self.session_base_dir, self.my_key) @inlineCallbacks def tearDown(self): diff --git a/Tribler/Test/Core/Modules/test_gigachannel_manager.py b/Tribler/Test/Core/Modules/test_gigachannel_manager.py index ffbbe5b77bd..5697b807eb9 100644 --- a/Tribler/Test/Core/Modules/test_gigachannel_manager.py +++ b/Tribler/Test/Core/Modules/test_gigachannel_manager.py @@ -36,8 +36,7 @@ def setUp(self): my_key = default_eccrypto.generate_key(u"curve25519") self.mock_session = MockObject() self.mock_session.lm = MockObject() - self.mock_session.lm.mds = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, - my_key) + self.mock_session.lm.mds = MetadataStore(":memory:", self.session_base_dir, my_key) self.chanman = GigaChannelManager(self.mock_session) diff --git a/Tribler/Test/Core/Utilities/test_tracker_utils.py b/Tribler/Test/Core/Utilities/test_tracker_utils.py index fdffeaea5f7..ea453917222 100644 --- a/Tribler/Test/Core/Utilities/test_tracker_utils.py +++ b/Tribler/Test/Core/Utilities/test_tracker_utils.py @@ -71,7 +71,7 @@ def test_skip_truncated_url(self): self.assertIsNone(result) def test_skip_wrong_url_scheme(self): - result = get_uniformed_tracker_url(u'ftp://tracker.1337x.org:80/announce') + result = get_uniformed_tracker_url(u'wss://tracker.1337x.org:80/announce') self.assertIsNone(result) def test_skip_value_error(self): From a7be546d404c44b8e02c74cd3c0e369201668d5c Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Fri, 15 Feb 2019 21:07:56 +0100 Subject: [PATCH 33/38] Torrent health events --- .gitignore | 4 + .../OrmBindings/channel_metadata.py | 104 ++++++++++++------ .../MetadataStore/OrmBindings/channel_node.py | 25 ----- .../OrmBindings/torrent_metadata.py | 38 ++++++- Tribler/Core/Modules/gigachannel_manager.py | 2 +- .../Modules/restapi/downloads_endpoint.py | 20 +--- .../Core/Modules/restapi/events_endpoint.py | 4 + .../Core/Modules/restapi/metadata_endpoint.py | 34 +++--- .../Modules/restapi/mychannel_endpoint.py | 51 +-------- Tribler/Core/Notifier.py | 3 +- Tribler/Core/Session.py | 36 ------ .../Core/TorrentChecker/torrent_checker.py | 28 +++-- Tribler/Core/Upgrade/db72_to_pony.py | 11 -- .../MetadataStore/test_channel_metadata.py | 43 +++++++- .../Modules/MetadataStore/test_metadata.py | 7 ++ .../Core/Modules/MetadataStore/test_store.py | 10 ++ .../MetadataStore/test_torrent_metadata.py | 21 ++-- .../RestApi/test_downloads_endpoint.py | 42 +++++++ .../Modules/RestApi/test_metadata_endpoint.py | 10 +- .../Test/Core/Upgrade/test_db72_to_pony.py | 3 +- .../nested_dir/corrupt_torrent.torrent | 1 + .../ubuntu-15.04-desktop-amd64.iso.torrent | Bin 0 -> 44258 bytes TriblerGUI/event_request_manager.py | 5 +- TriblerGUI/widgets/channelpage.py | 2 + TriblerGUI/widgets/tablecontentdelegate.py | 2 +- TriblerGUI/widgets/tablecontentmodel.py | 25 ++++- TriblerGUI/widgets/torrentdetailstabwidget.py | 13 ++- 27 files changed, 313 insertions(+), 231 deletions(-) create mode 100644 Tribler/Test/data/linux_torrents/nested_dir/corrupt_torrent.torrent create mode 100644 Tribler/Test/data/linux_torrents/nested_dir/ubuntu-15.04-desktop-amd64.iso.torrent diff --git a/.gitignore b/.gitignore index fc2c6d84d48..bad786de0c3 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ # Apple .DS_Store + +*.autosave +*.stats .*project local.properties *.bak @@ -41,6 +44,7 @@ htmlcov/ # Ignore nosetest output logs Tribler/Test/logs/ +Tribler/Test/Core/logs/ # Ignore trial temp dirs _trial_temp*/ _trial_temp*.lock diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py index 39a20b3dfdb..f98e9b0ab8d 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_metadata.py @@ -1,6 +1,7 @@ from __future__ import absolute_import import os +import sys from binascii import hexlify from datetime import datetime from libtorrent import add_files, bencode, create_torrent, file_storage, set_piece_hashes, torrent_info @@ -13,6 +14,7 @@ from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import COMMITTED, NEW, PUBLIC_KEY_LEN, TODELETE, \ LEGACY_ENTRY from Tribler.Core.Modules.MetadataStore.serialization import CHANNEL_TORRENT, ChannelMetadataPayload +from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.Utilities.tracker_utils import get_uniformed_tracker_url from Tribler.Core.exceptions import DuplicateChannelIdError, DuplicateTorrentFileError from Tribler.pyipv8.ipv8.database import database_blob @@ -23,6 +25,12 @@ ROOT_CHANNEL_ID = 0 +def chunks(l, n): + """Yield successive n-sized chunks from l.""" + for i in range(0, len(l), n): + yield l[i:i + n] + + def create_torrent_from_dir(directory, torrent_filename): fs = file_storage() add_files(fs, directory) @@ -122,7 +130,7 @@ def get_my_channel(cls): @classmethod @db_session - def create_channel(cls, title, description): + def create_channel(cls, title, description=""): """ Create a channel and sign it with a given key. :param title: The title of the channel @@ -243,7 +251,7 @@ def get_torrent(self, infohash): return db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) @db_session - def add_torrent_to_channel(self, tdef, extra_info): + def add_torrent_to_channel(self, tdef, extra_info=None): """ Add a torrent to your channel. :param tdef: The torrent definition file of the torrent to add @@ -342,24 +350,6 @@ def delete_torrent(self, infohash): return True - @db_session - def cancel_torrent_deletion(self, infohash): - """ - Cancel pending removal of torrent marked for deletion. - :param infohash: The infohash of the torrent to act upon - :return True if deleteion cancelled, False if no MD with the given infohash found - """ - if self.get_torrent(infohash): - torrent_metadata = db.TorrentMetadata.get(public_key=self.public_key, infohash=infohash) - else: - return False - - # As any NEW metadata is deleted immediately, only COMMITTED -> TODELETE - # Therefore we restore the entry's status to COMMITTED - if torrent_metadata.status == TODELETE: - torrent_metadata.status = COMMITTED - return True - @classmethod @db_session def get_channel_with_id(cls, channel_id): @@ -423,10 +413,6 @@ def get_random_channels(cls, limit, only_subscribed=False): def get_random_torrents(self, limit): return self.contents.random(limit) - @db_session - def remove_contents(self): - self.contents.delete() - @classmethod @db_session def get_updated_channels(cls): @@ -434,24 +420,18 @@ def get_updated_channels(cls): @classmethod @db_session - def get_channels(cls, first=1, last=50, subscribed=False, hide_xxx=False, **kwargs): + def get_entries(cls, first=None, last=None, subscribed=False, metadata_type=CHANNEL_TORRENT, **kwargs): """ Get some channels. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - pony_query = ChannelMetadata.get_entries_query(**kwargs) - - # Filter subscribed/non-subscribed + pony_query, count = super(ChannelMetadata, cls).get_entries(metadata_type=metadata_type, **kwargs) if subscribed: pony_query = pony_query.where(subscribed=subscribed) - if hide_xxx: - pony_query = pony_query.where(lambda g: g.xxx == 0) - total_results = pony_query.count() - - return pony_query[first - 1:last], total_results + return pony_query[(first or 1) - 1:last] if first or last else pony_query, count @db_session def to_simple_dict(self): @@ -471,4 +451,62 @@ def to_simple_dict(self): "my_channel": database_blob(self._my_key.pub().key_to_bin()[10:]) == database_blob(self.public_key) } + @classmethod + @db_session + def get_channel_name(cls, name, infohash): + """ + Try to translate a Tribler download name into matching channel name. By searching for a channel with the + given dirname and/or infohash. Try do determine if infohash belongs to an older version of + some channel we already have. + :param name - name of the download. Should match the directory name of the channel. + :param infohash - infohash of the download. + :return: Channel title as a string, prefixed with 'OLD:' for older versions + """ + try: + channel = cls.get_channel_with_dirname(name) + except UnicodeEncodeError: + channel = cls.get_channel_with_infohash(infohash) + + if not channel: + return name + if channel.infohash == database_blob(infohash): + return channel.title + else: + return u'OLD:' + channel.title + + @db_session + def add_torrents_from_dir(self, torrents_dir, recursive=False): + # TODO: Optimize this properly!!!! + torrents_list = [] + errors_list = [] + + if recursive: + def rec_gen(): + for root, _, filenames in os.walk(torrents_dir): + for fn in filenames: + yield os.path.join(root, fn) + + filename_generator = rec_gen() + else: + filename_generator = os.listdir(torrents_dir) + + # Build list of .torrents to process + for f in filename_generator: + filepath = os.path.join(torrents_dir, f) + filename = str(filepath) if sys.platform == 'win32' else filepath.decode('utf-8') + if os.path.isfile(filepath) and filename.endswith(u'.torrent'): + torrents_list.append(filepath) + + for chunk in chunks(torrents_list, 100): # 100 is a reasonable chunk size for commits + for f in chunk: + try: + self.add_torrent_to_channel(TorrentDef.load(f)) + except DuplicateTorrentFileError: + pass + except: + errors_list.append(f) + orm.commit() # Kinda optimization to drop excess cache? + + return torrents_list, errors_list + return ChannelMetadata diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py index 5c832c3ee02..a01414ff328 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/channel_node.py @@ -4,7 +4,6 @@ from datetime import datetime from pony import orm -from pony.orm import db_session, select, desc from pony.orm.core import DEFAULT from Tribler.Core.Modules.MetadataStore.serialization import DeletedMetadataPayload, DELETED, \ @@ -196,28 +195,4 @@ def from_payload(cls, payload): def from_dict(cls, dct): return cls(**dct) - @classmethod - @db_session - def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): - """ - Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based - on a keyword/whether you are subscribed to it. - :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates - the total number of results, regardless the passed first/last parameter. - """ - # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! - pony_query = select(g for g in cls) - - # Filter the results on a keyword or some keywords - if query_filter: - pony_query = cls.search_keyword(query_filter, lim=1000) - - # Sort the query - if sort_by: - sort_expression = "g." + sort_by - sort_expression = sort_expression if sort_asc else desc(sort_expression) - pony_query = pony_query.sort_by(sort_expression) - - return pony_query - return ChannelNode diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index 61d26c22b15..eea68cefec5 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -4,7 +4,7 @@ from datetime import datetime from pony import orm -from pony.orm import db_session, raw_sql +from pony.orm import db_session, raw_sql, select, desc from Tribler.Core.Category.FamilyFilter import default_xxx_filter from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import LEGACY_ENTRY, TODELETE @@ -103,17 +103,43 @@ def get_random_torrents(cls, limit): @classmethod @db_session - def get_torrents(cls, first=1, last=50, channel_pk=False, exclude_deleted=False, hide_xxx=False, **kwargs): + def get_entries_query(cls, sort_by=None, sort_asc=True, query_filter=None): + """ + Get some metadata entries. Optionally sort the results by a specific field, or filter the channels based + on a keyword/whether you are subscribed to it. + :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates + the total number of results, regardless the passed first/last parameter. + """ + # Warning! For Pony magic to work, iteration variable name (e.g. 'g') should be the same everywhere! + # Filter the results on a keyword or some keywords + pony_query = cls.search_keyword(query_filter, lim=1000) if query_filter else select(g for g in cls) + + # Sort the query + if sort_by: + if sort_by == "HEALTH": + pony_query = pony_query.sort_by("(g.health.seeders, g.health.leechers)") if sort_asc else \ + pony_query.sort_by("(desc(g.health.seeders), desc(g.health.leechers))") + else: + sort_expression = "g." + sort_by + sort_expression = sort_expression if sort_asc else desc(sort_expression) + pony_query = pony_query.sort_by(sort_expression) + return pony_query + + + @classmethod + @db_session + def get_entries(cls, first=None, last=None, metadata_type=REGULAR_TORRENT, channel_pk=False, + exclude_deleted=False, hide_xxx=False, **kwargs): """ Get some torrents. Optionally sort the results by a specific field, or filter the channels based on a keyword/whether you are subscribed to it. :return: A tuple. The first entry is a list of ChannelMetadata entries. The second entry indicates the total number of results, regardless the passed first/last parameter. """ - pony_query = TorrentMetadata.get_entries_query(**kwargs) + pony_query = cls.get_entries_query(**kwargs) # We only want torrents, not channel torrents - pony_query = pony_query.where(metadata_type=REGULAR_TORRENT) + pony_query = pony_query.where(metadata_type=metadata_type) if exclude_deleted: pony_query = pony_query.where(lambda g: g.status != TODELETE) if hide_xxx: @@ -123,9 +149,9 @@ def get_torrents(cls, first=1, last=50, channel_pk=False, exclude_deleted=False, if channel_pk: pony_query = pony_query.where(public_key=channel_pk) - total_results = pony_query.count() + count = pony_query.count() - return pony_query[first - 1:last], total_results + return pony_query[(first or 1) - 1:last] if first or last else pony_query, count @db_session def to_simple_dict(self, include_trackers=False): diff --git a/Tribler/Core/Modules/gigachannel_manager.py b/Tribler/Core/Modules/gigachannel_manager.py index c275dd583b3..a0253c1b470 100644 --- a/Tribler/Core/Modules/gigachannel_manager.py +++ b/Tribler/Core/Modules/gigachannel_manager.py @@ -66,7 +66,7 @@ def remove_cruft_channels(self): :return: list of tuples (download_to_remove=download, remove_files=Bool) """ with db_session: - channels, _ = self.session.lm.mds.ChannelMetadata.get_channels(last=10000, subscribed=True) + channels, _ = self.session.lm.mds.ChannelMetadata.get_entries(last=10000, subscribed=True) subscribed_infohashes = [bytes(c.infohash) for c in list(channels)] dirnames = [c.dir_name for c in channels] diff --git a/Tribler/Core/Modules/restapi/downloads_endpoint.py b/Tribler/Core/Modules/restapi/downloads_endpoint.py index 57b5234ebb1..cc444d26e52 100644 --- a/Tribler/Core/Modules/restapi/downloads_endpoint.py +++ b/Tribler/Core/Modules/restapi/downloads_endpoint.py @@ -19,7 +19,6 @@ from Tribler.Core.Utilities.utilities import unichar_string from Tribler.Core.exceptions import InvalidSignatureException from Tribler.Core.simpledefs import DLMODE_VOD, DOWNLOAD, UPLOAD, dlstatus_strings -from Tribler.pyipv8.ipv8.database import database_blob from Tribler.util import cast_to_unicode_utf8 @@ -227,23 +226,8 @@ def render_GET(self, request): num_seeds, num_peers = state.get_num_seeds_peers() num_connected_seeds, num_connected_peers = download.get_num_connected_seeds_peers() - @db_session - def get_chant_name(name, infohash): - channel = None - try: - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_dirname(name) - except UnicodeEncodeError: - channel = self.session.lm.mds.ChannelMetadata.get_channel_with_infohash(infohash) - - if not channel: - return name - if channel.infohash == database_blob(infohash): - return channel.title - else: - return u'OLD:' + channel.title - - download_json = {"name": get_chant_name(tdef.get_name_utf8(), tdef.get_infohash()) - if download.get_channel_download() else tdef.get_name_utf8(), + download_json = {"name": self.session.lm.mds.ChannelMetadata.get_channel_name( + tdef.get_name_utf8(), tdef.get_infohash()) if download.get_channel_download() else tdef.get_name_utf8(), "progress": state.get_progress(), "infohash": tdef.get_infohash().encode('hex'), "speed_down": state.get_current_payload_speed(DOWNLOAD), diff --git a/Tribler/Core/Modules/restapi/events_endpoint.py b/Tribler/Core/Modules/restapi/events_endpoint.py index dc7c4178754..73b7ced2f3d 100644 --- a/Tribler/Core/Modules/restapi/events_endpoint.py +++ b/Tribler/Core/Modules/restapi/events_endpoint.py @@ -80,6 +80,7 @@ def __init__(self, session): self.session.add_observer(self.on_torrent_discovered, NTFY_TORRENT, [NTFY_DISCOVERED]) self.session.add_observer(self.on_torrent_finished, NTFY_TORRENT, [NTFY_FINISHED]) self.session.add_observer(self.on_torrent_error, NTFY_TORRENT, [NTFY_ERROR]) + self.session.add_observer(self.on_torrent_info_updated, NTFY_TORRENT, [NTFY_UPDATE]) self.session.add_observer(self.on_market_ask, NTFY_MARKET_ON_ASK, [NTFY_UPDATE]) self.session.add_observer(self.on_market_bid, NTFY_MARKET_ON_BID, [NTFY_UPDATE]) self.session.add_observer(self.on_market_ask_timeout, NTFY_MARKET_ON_ASK_TIMEOUT, [NTFY_UPDATE]) @@ -138,6 +139,9 @@ def on_torrent_finished(self, subject, changetype, objectID, *args): def on_torrent_error(self, subject, changetype, objectID, *args): self.write_data({"type": "torrent_error", "event": {"infohash": objectID.encode('hex'), "error": args[0]}}) + def on_torrent_info_updated(self, subject, changetype, objectID, *args): + self.write_data({"type": "torrent_info_updated", "event": dict(infohash=objectID.encode('hex'), **args[0])}) + def on_tribler_exception(self, exception_text): self.write_data({"type": "tribler_exception", "event": {"text": exception_text}}) diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index fded85bc2cb..392c127f510 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -59,7 +59,7 @@ def convert_sort_param_to_pony_col(sort_param): u'date': "torrent_date", u'status': 'status', u'torrents': 'num_entries', - u'health': 'health.seeders' + u'health': 'HEALTH' } if sort_param not in json2pony_columns: @@ -96,12 +96,12 @@ def getChild(self, path, request): def render_GET(self, request): sanitized = ChannelsEndpoint.sanitize_parameters(request.args) - channels, total = self.session.lm.mds.ChannelMetadata.get_channels(**sanitized) - - channels = [channel.to_simple_dict() for channel in channels] + with db_session: + channels, total = self.session.lm.mds.ChannelMetadata.get_entries(**sanitized) + channels_list = [channel.to_simple_dict() for channel in channels] return json.dumps({ - "channels": channels, + "channels": channels_list, "first": sanitized["first"], "last": sanitized["last"], "sort_by": sanitized["sort_by"], @@ -179,11 +179,11 @@ def __init__(self, session, channel_pk): def render_GET(self, request): sanitized = SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) with db_session: - torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents(channel_pk=self.channel_pk, **sanitized) - torrents = [torrent.to_simple_dict() for torrent in torrents] + torrents, total = self.session.lm.mds.TorrentMetadata.get_entries(channel_pk=self.channel_pk, **sanitized) + torrents_list = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ - "torrents": torrents, + "torrents": torrents_list, "first": sanitized['first'], "last": sanitized['last'], "sort_by": sanitized['sort_by'], @@ -305,6 +305,10 @@ def render_GET(self, request): if 'refresh' in request.args and request.args['refresh'] and request.args['refresh'][0] == "1": refresh = True + nowait = False + if 'nowait' in request.args and request.args['nowait'] and request.args['nowait'][0] == "1": + nowait = True + def on_health_result(result): request.write(json.dumps({'health': result})) self.finish_request(request) @@ -317,14 +321,10 @@ def on_request_error(failure): if not request.finished: self.finish_request(request) - with db_session: - md_list = list(self.session.lm.mds.TorrentMetadata.select(lambda g: - g.infohash == database_blob(self.infohash))) - if not md_list: - request.setResponseCode(http.NOT_FOUND) - request.write(json.dumps({"error": "torrent not found in database"})) - - self.session.check_torrent_health(self.infohash, timeout=timeout, scrape_now=refresh) \ - .addCallback(on_health_result).addErrback(on_request_error) + result_deferred = self.session.check_torrent_health(self.infohash, timeout=timeout, scrape_now=refresh) + # return immediately. Used by GUI to schedule health updates through the EventsEndpoint + if nowait: + return json.dumps({'checking': '1'}) + result_deferred.addCallback(on_health_result).addErrback(on_request_error) return NOT_DONE_YET diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index 4d2316c6a42..81d89a18c43 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -3,7 +3,6 @@ import base64 import json import os -import sys import urllib from binascii import unhexlify, hexlify @@ -13,12 +12,7 @@ from Tribler.Core.Modules.restapi.metadata_endpoint import SpecificChannelTorrentsEndpoint from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.exceptions import DuplicateTorrentFileError - - -def chunks(l, n): - """Yield successive n-sized chunks from l.""" - for i in range(0, len(l), n): - yield l[i:i + n] +from Tribler.pyipv8.ipv8.database import database_blob class BaseMyChannelEndpoint(resource.Resource): @@ -109,12 +103,12 @@ def render_GET(self, request): request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": "your channel has not been created"}) - request.args['channel_pk'] = [str(my_channel.public_key).encode('hex')] sanitized = SpecificChannelTorrentsEndpoint.sanitize_parameters(request.args) if 'exclude_deleted' in request.args: sanitized['exclude_deleted'] = request.args['exclude_deleted'] - torrents, total = self.session.lm.mds.TorrentMetadata.get_torrents(**sanitized) + torrents, total = self.session.lm.mds.TorrentMetadata.get_entries( + channel_pk=database_blob(my_channel.public_key), **sanitized) torrents = [torrent.to_simple_dict() for torrent in torrents] return json.dumps({ @@ -157,10 +151,7 @@ def render_DELETE(self, request): request.setResponseCode(http.NOT_FOUND) return json.dumps({"error": "your channel has not been created"}) - # Remove all torrents in your channel - torrents = my_channel.contents_list - for torrent in torrents: - my_channel.delete_torrent(torrent.infohash) + my_channel.drop_channel_contents() return json.dumps({"success": True}) @@ -230,36 +221,7 @@ def render_PUT(self, request): return json.dumps({"the torrents_dir parameter should be provided when the rec"}) if torrents_dir: - torrents_list = [] - errors_list = [] - - if recursive: - def rec_gen(): - for root, _, filenames in os.walk(torrents_dir): - for fn in filenames: - yield os.path.join(root, fn) - - filename_generator = rec_gen() - else: - filename_generator = os.listdir(torrents_dir) - - # Build list of .torrents to process - for f in filename_generator: - filepath = os.path.join(torrents_dir, f) - filename = str(filepath) if sys.platform == 'win32' else filepath.decode('utf-8') - if os.path.isfile(filepath) and filename.endswith(u'.torrent'): - torrents_list.append(filepath) - - for chunk in chunks(torrents_list, 100): # 100 is a reasonable chunk size for commits - with db_session: - for f in chunk: - try: - my_channel.add_torrent_to_channel(TorrentDef.load(f), {}) - except DuplicateTorrentFileError: - pass - except: - errors_list.append(f) - + torrents_list, errors_list = my_channel.add_torrents_from_dir(torrents_dir, recursive) return json.dumps({"added": len(torrents_list), "errors": errors_list}) if 'torrent' not in parameters or len(parameters['torrent']) == 0: @@ -293,7 +255,6 @@ class MyChannelSpecificTorrentEndpoint(BaseMyChannelEndpoint): def __init__(self, session, infohash): BaseMyChannelEndpoint.__init__(self, session) self.infohash = unhexlify(infohash) - @db_session def render_PATCH(self, request): parameters = http.parse_qs(request.content.read(), 1) @@ -328,6 +289,6 @@ def render_POST(self, request): torrent_dict = my_channel.commit_channel_torrent() if torrent_dict: - self.session.lm.gigachannel_manager.updated_my_channel(TorrentDef(metainfo=torrent_dict)) + self.session.lm.gigachannel_manager.updated_my_channel(TorrentDef.load_from_dict(torrent_dict)) return json.dumps({"success": True}) diff --git a/Tribler/Core/Notifier.py b/Tribler/Core/Notifier.py index 0d003b06cd2..a434a8d7e02 100644 --- a/Tribler/Core/Notifier.py +++ b/Tribler/Core/Notifier.py @@ -42,7 +42,8 @@ def __init__(self): self.observertimers = {} self.observerLock = threading.Lock() - def add_observer(self, func, subject, changeTypes=[NTFY_UPDATE, NTFY_INSERT, NTFY_DELETE], id=None, cache=0): + def add_observer(self, func, subject, changeTypes=None, id=None, cache=0): + changeTypes = changeTypes or [NTFY_UPDATE, NTFY_INSERT, NTFY_DELETE] """ Add observer function which will be called upon certain event Example: diff --git a/Tribler/Core/Session.py b/Tribler/Core/Session.py index 44514bb0b68..c0dc9c3a080 100644 --- a/Tribler/Core/Session.py +++ b/Tribler/Core/Session.py @@ -530,42 +530,6 @@ def create_channel(self, name, description, mode=u'closed'): """ return self.lm.channel_manager.create_channel(name, description, mode) - def add_torrent_def_to_channel(self, channel_id, torrent_def, extra_info=None, forward=True): - """ - Adds a TorrentDef to a Channel. - - :param channel_id: id of the Channel to add the Torrent to - :param torrent_def: definition of the Torrent to add - :param extra_info: description of the Torrent to add - :param forward: when True the messages are forwarded (as defined by their message - destination policy) to other nodes in the community. This parameter should (almost always) - be True, its inclusion is mostly to allow certain debugging scenarios - """ - extra_info = extra_info or {} - # Make sure that this new torrent_def is also in collected torrents - self.lm.rtorrent_handler.save_torrent(torrent_def) - - channelcast_db = self.open_dbhandler(NTFY_CHANNELCAST) - if channelcast_db.hasTorrent(channel_id, torrent_def.infohash): - raise DuplicateTorrentFileError("This torrent file already exists in your channel.") - - dispersy_cid = str(channelcast_db.getDispersyCIDFromChannelId(channel_id)) - community = self.get_dispersy_instance().get_community(dispersy_cid) - - community._disp_create_torrent( - torrent_def.infohash, - long(time.time()), - torrent_def.get_name_as_unicode(), - tuple(torrent_def.get_files_with_length()), - torrent_def.get_trackers_as_single_tuple(), - forward=forward) - - if 'description' in extra_info: - desc = extra_info['description'].strip() - if desc != '': - data = channelcast_db.getTorrentFromChannelId(channel_id, torrent_def.infohash, ['ChannelTorrents.id']) - community.modifyTorrent(data, {'description': desc}, forward=forward) - def check_torrent_health(self, infohash, timeout=20, scrape_now=False): """ Checks the given torrent's health on its trackers. diff --git a/Tribler/Core/TorrentChecker/torrent_checker.py b/Tribler/Core/TorrentChecker/torrent_checker.py index a395c060ac7..c689284cd18 100644 --- a/Tribler/Core/TorrentChecker/torrent_checker.py +++ b/Tribler/Core/TorrentChecker/torrent_checker.py @@ -15,6 +15,7 @@ from Tribler.Core.TorrentChecker.session import create_tracker_session, FakeDHTSession, UdpSocketManager from Tribler.Core.Utilities.tracker_utils import MalformedTrackerURLException from Tribler.Core.Utilities.utilities import is_valid_url +from Tribler.Core.simpledefs import NTFY_TORRENT, NTFY_UPDATE from Tribler.pyipv8.ipv8.database import database_blob from Tribler.pyipv8.ipv8.taskmanager import TaskManager @@ -189,10 +190,9 @@ def on_gui_request_completed(self, infohash, result): s = response[response.keys()[0]][0]['seeders'] l = response[response.keys()[0]][0]['leechers'] - # Less leeches is better, except for the zero seeds case. I.e. s0 l2 > s0 l1 + # More leeches is better, because undefined peers are marked as leeches in DHT if s > torrent_update_dict['seeders'] or \ - (s == torrent_update_dict['seeders'] and l < torrent_update_dict['leechers']) or \ - (s == 0 and torrent_update_dict['seeders'] == 0 and l > torrent_update_dict['leechers']): + (s == torrent_update_dict['seeders'] and l > torrent_update_dict['leechers']): torrent_update_dict['seeders'] = s torrent_update_dict['leechers'] = l @@ -201,9 +201,15 @@ def on_gui_request_completed(self, infohash, result): # Add this result to popularity community to publish to subscribers self.publish_torrent_result(torrent_update_dict) + # TODO: DRY! Stop doing lots of formats, just make REST endpoint automatically encode binary data to hex! + self.tribler_session.notifier.notify(NTFY_TORRENT, NTFY_UPDATE, infohash, + {"num_seeders": torrent_update_dict["seeders"], + "num_leechers": torrent_update_dict["leechers"], + "last_tracker_check": torrent_update_dict["last_check"], + "health": "updated"}) return final_response - def add_gui_request(self, infohash, timeout=20, scrape_now=False): + def add_gui_request(self, infohash, timeout=20, scrape_now=False, notify=False): """ Public API for adding a GUI request. :param infohash: Torrent infohash. @@ -222,7 +228,8 @@ def add_gui_request(self, infohash, timeout=20, scrape_now=False): if time_diff < self._torrent_check_interval and not scrape_now: self._logger.debug(u"time interval too short, skip GUI request. infohash: %s", hexlify(infohash)) return succeed({"db": {"seeders": result.seeders, - "leechers": result.leechers, "infohash": hexlify(infohash)}}) + "leechers": result.leechers, + "infohash": hexlify(infohash)}}) # get torrent's tracker list from DB tracker_set = self.get_valid_trackers_of_torrent(torrent_id) @@ -297,18 +304,17 @@ def _update_torrent_result(self, response): leechers = response['leechers'] last_check = response['last_check'] - # the torrent status logic, TODO: do it in other way self._logger.debug(u"Update result %s/%s for %s", seeders, leechers, hexlify(infohash)) with db_session: # Update torrent state - result = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(infohash)) - if not result: + torrent = self.tribler_session.lm.mds.TorrentState.get(infohash=database_blob(infohash)) + if not torrent: # Something is wrong, there should exist a corresponding TorrentState entry in the DB. return - result.seeders = seeders - result.leechers = leechers - result.last_check = last_check + torrent.seeders = seeders + torrent.leechers = leechers + torrent.last_check = last_check def publish_torrent_result(self, response): if response['seeders'] == 0: diff --git a/Tribler/Core/Upgrade/db72_to_pony.py b/Tribler/Core/Upgrade/db72_to_pony.py index ee6dda5b4dd..a6c767dfc5e 100644 --- a/Tribler/Core/Upgrade/db72_to_pony.py +++ b/Tribler/Core/Upgrade/db72_to_pony.py @@ -315,14 +315,3 @@ def mark_conversion_finished(self): v.set(value=CONVERSION_FINISHED) else: self.mds.MiscData(name=CONVERSION_FROM_72, value=CONVERSION_FINISHED) - - -if __name__ == "__main__": - my_key = default_eccrypto.generate_key(u"curve25519") - mds = MetadataStore("/tmp/metadata.db", "/tmp", my_key) - d = DispersyToPonyMigration("/tmp/tribler.sdb", mds) - - d.initialize() - d.convert_personal_channel() - d.convert_discovered_channels() - d.update_trackers_info() diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py index 7db34d802cd..7775036e825 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_channel_metadata.py @@ -11,7 +11,7 @@ from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_metadata import CHANNEL_DIR_NAME_LENGTH, ROOT_CHANNEL_ID, \ entries_to_chunk from Tribler.Core.Modules.MetadataStore.OrmBindings.channel_node import NEW, TODELETE, COMMITTED -from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload +from Tribler.Core.Modules.MetadataStore.serialization import ChannelMetadataPayload, REGULAR_TORRENT from Tribler.Core.Modules.MetadataStore.store import MetadataStore from Tribler.Core.TorrentDef import TorrentDef from Tribler.Core.exceptions import DuplicateTorrentFileError, DuplicateChannelIdError @@ -194,7 +194,7 @@ def test_add_torrent_to_channel(self): """ channel_metadata = self.mds.ChannelMetadata.create_channel('test', 'test') tdef = TorrentDef.load(TORRENT_UBUNTU_FILE) - channel_metadata.add_torrent_to_channel(tdef, None) + channel_metadata.add_torrent_to_channel(tdef, {'description': 'blabla'}) self.assertTrue(channel_metadata.contents_list) self.assertRaises(DuplicateTorrentFileError, channel_metadata.add_torrent_to_channel, tdef, None) @@ -299,19 +299,50 @@ def test_get_channels(self): for ind in xrange(10): self.mds.ChannelNode._my_key = default_eccrypto.generate_key('low') _ = self.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0)) - channels = self.mds.ChannelMetadata.get_channels(first=1, last=5) + channels = self.mds.ChannelMetadata.get_entries(first=1, last=5) self.assertEqual(len(channels[0]), 5) self.assertEqual(channels[1], 10) # Test filtering - channels = self.mds.ChannelMetadata.get_channels(first=1, last=5, query_filter='channel5') + channels = self.mds.ChannelMetadata.get_entries(first=1, last=5, query_filter='channel5') self.assertEqual(len(channels[0]), 1) # Test sorting - channels = self.mds.ChannelMetadata.get_channels(first=1, last=10, sort_by='title', sort_asc=False) + channels = self.mds.ChannelMetadata.get_entries(first=1, last=10, sort_by='title', sort_asc=False) self.assertEqual(len(channels[0]), 10) self.assertEqual(channels[0][0].title, 'channel9') # Test fetching subscribed channels - channels = self.mds.ChannelMetadata.get_channels(first=1, last=10, sort_by='title', subscribed=True) + channels = self.mds.ChannelMetadata.get_entries(first=1, last=10, sort_by='title', subscribed=True) self.assertEqual(len(channels[0]), 5) + + @db_session + def test_get_channel_name(self): + infohash = "\x00" * 20 + title = "testchan" + chan = self.mds.ChannelMetadata(title=title, infohash=database_blob(infohash)) + dirname = chan.dir_name + + self.assertEqual(title, self.mds.ChannelMetadata.get_channel_name(dirname, infohash)) + chan.infohash = "\x11" * 20 + self.assertEqual("OLD:" + title, self.mds.ChannelMetadata.get_channel_name(dirname, infohash)) + chan.delete() + self.assertEqual(dirname, self.mds.ChannelMetadata.get_channel_name(dirname, infohash)) + + @db_session + def check_add(self, torrents_in_dir, errors, recursive): + TEST_TORRENTS_DIR = os.path.join(os.path.abspath(os.path.dirname(os.path.realpath(__file__))), + '..', '..', '..', 'data', 'linux_torrents') + chan = self.mds.ChannelMetadata.create_channel(title='testchan') + torrents, e = chan.add_torrents_from_dir(TEST_TORRENTS_DIR, recursive) + self.assertEqual(torrents_in_dir, len(torrents)) + self.assertEqual(errors, len(e)) + with db_session: + q = self.mds.TorrentMetadata.select(lambda g: g.metadata_type == REGULAR_TORRENT) + self.assertEqual(torrents_in_dir - len(e), q.count()) + + def test_add_torrents_from_dir(self): + self.check_add(9, 0, recursive=False) + + def test_add_torrents_from_dir_recursive(self): + self.check_add(11, 1, recursive=True) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py index 018517eeb18..1f085864d6e 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py @@ -93,6 +93,13 @@ def test_has_valid_signature(self): metadata = self.mds.ChannelNode(skip_key_check=True, **md_dict) self.assertFalse(metadata.has_valid_signature()) + key = default_eccrypto.generate_key(u"curve25519") + metadata2 = self.mds.ChannelNode(sign_with=key, **md_dict) + self.assertTrue(database_blob(key.pub().key_to_bin()[10:]), metadata2.public_key) + md_dict2 = metadata2.to_dict() + md_dict2["signature"] = md_dict["signature"] + self.assertRaises(InvalidSignatureException, self.mds.ChannelNode, **md_dict2) + @db_session def test_from_payload(self): """ diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_store.py b/Tribler/Test/Core/Modules/MetadataStore/test_store.py index db45203475a..7e4d4e73e3d 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_store.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_store.py @@ -49,6 +49,16 @@ def tearDown(self): self.mds.shutdown() yield super(TestMetadataStore, self).tearDown() + + def test_store_clock(self): + my_key = default_eccrypto.generate_key(u"curve25519") + mds2 = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, my_key) + tick = mds2.clock.tick() + mds2.shutdown() + mds2 = MetadataStore(os.path.join(self.session_base_dir, 'test.db'), self.session_base_dir, my_key) + self.assertEqual(mds2.clock.clock, tick) + mds2.shutdown() + @db_session def test_process_channel_dir_file(self): """ diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py index 379cc6fd673..100b9daea6b 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_torrent_metadata.py @@ -172,7 +172,7 @@ def test_get_autocomplete_terms_max(self): autocomplete_terms = self.mds.TorrentMetadata.get_auto_complete_terms(".", 2) @db_session - def test_get_torrents(self): + def test_get_entries(self): """ Test whether we can get torrents """ @@ -186,19 +186,20 @@ def test_get_torrents(self): tlist[-1].xxx = 1 tlist[-2].status = TODELETE - torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=5) - self.assertEqual(len(torrents[0]), 5) - self.assertEqual(torrents[1], 25) + torrents, count = self.mds.TorrentMetadata.get_entries(first=1, last=5) + self.assertEqual(5, len(torrents)) + self.assertEqual(25, count) # Test fetching torrents in a channel channel_pk = self.mds.ChannelNode._my_key.pub().key_to_bin()[10:] - torrents = self.mds.ChannelMetadata.get_torrents(first=1, last=10, sort_by='title', channel_pk=channel_pk) - self.assertEqual(len(torrents[0]), 5) - self.assertEqual(torrents[1], 5) + torrents, count = self.mds.TorrentMetadata.get_entries(first=1, last=10, sort_by='title', channel_pk=channel_pk) + self.assertEqual(5, len(torrents)) + self.assertEqual(5, count) - torrents = self.mds.ChannelMetadata.get_torrents(channel_pk=channel_pk, hide_xxx=True, exclude_deleted=True) - self.assertListEqual(torrents[0][:], tlist[-5:-2]) - self.assertEqual(torrents[1], 3) + torrents, count = self.mds.TorrentMetadata.get_entries(channel_pk=channel_pk, hide_xxx=True, exclude_deleted=True)[:] + + self.assertListEqual(tlist[-5:-2], list(torrents)) + self.assertEqual(count, 3) @db_session def test_metadata_conflicting(self): diff --git a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py index ce50d7ad425..0335948c67e 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_downloads_endpoint.py @@ -8,6 +8,7 @@ from twisted.internet.defer import fail import Tribler.Core.Utilities.json_util as json +from Tribler.Core import TorrentDef from Tribler.Core.DownloadConfig import DownloadStartupConfig from Tribler.Core.DownloadState import DownloadState from Tribler.Core.Utilities.network_utils import get_random_port @@ -581,6 +582,18 @@ def verify_download(_): return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, expected_json=expected_json).addCallback(verify_download) + @trial_timeout(10) + def test_add_metadata_download_already_added(self): + """ + Test adding a channel metadata download to the Tribler core + """ + with db_session: + self.session.lm.mds.process_mdblob_file(os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')) + post_data = {'uri': 'file:%s' % os.path.join(TESTS_DIR, 'Core/data/sample_channel/channel.mdblob')} + expected_json = {u'error': u'Already subscribed'} + return self.do_request('downloads', expected_code=200, request_type='PUT', post_data=post_data, + expected_json=expected_json) + @trial_timeout(10) def test_add_metadata_download_invalid_sig(self): """ @@ -608,3 +621,32 @@ def test_add_invalid_metadata_download(self): post_data = {'uri': 'file:%s' % os.path.join(TESTS_DATA_DIR, 'notexisting.mdblob'), 'metadata_download': '1'} self.should_check_equality = False return self.do_request('downloads', expected_code=400, request_type='PUT', post_data=post_data) + + @trial_timeout(20) + def test_get_downloads_with_channels(self): + """ + Testing whether the API returns the right download when a download is added + """ + + test_channel_name = 'testchan' + + def verify_download(downloads): + downloads_json = json.loads(downloads) + self.assertEqual(len(downloads_json['downloads']), 3) + self.assertEqual(test_channel_name, + [d for d in downloads_json["downloads"] if d["channel_download"]][0]["name"]) + + video_tdef, _ = self.create_local_torrent(os.path.join(TESTS_DATA_DIR, 'video.avi')) + self.session.start_download_from_tdef(video_tdef, DownloadStartupConfig()) + self.session.start_download_from_uri("file:" + pathname2url( + os.path.join(TESTS_DATA_DIR, "bak_single.torrent"))) + + with db_session: + my_channel = self.session.lm.mds.ChannelMetadata.create_channel(test_channel_name, 'test') + my_channel.add_torrent_to_channel(video_tdef) + torrent_dict = my_channel.commit_channel_torrent() + self.session.lm.gigachannel_manager.updated_my_channel(TorrentDef.TorrentDef.load_from_dict(torrent_dict)) + + self.should_check_equality = False + return self.do_request('downloads?get_peers=1&get_pieces=1', + expected_code=200).addCallback(verify_download) diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index 0737492023f..9420e62a4aa 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -32,7 +32,7 @@ def setUp(self): for ind in xrange(10): self.session.lm.mds.ChannelNode._my_key = default_eccrypto.generate_key('curve25519') _ = self.session.lm.mds.ChannelMetadata(title='channel%d' % ind, subscribed=(ind % 2 == 0), - num_entries=torrents_per_channel) + num_entries=torrents_per_channel, infohash=random_infohash()) for torrent_ind in xrange(torrents_per_channel): rand_infohash = random_infohash() self.infohashes.append(rand_infohash) @@ -57,6 +57,14 @@ def on_response(response): self.should_check_equality = False return self.do_request('metadata/channels?sort_by=title', expected_code=200).addCallback(on_response) + def test_get_channels_sort_by_health(self): + def on_response(response): + json_dict = json.loads(response) + self.assertEqual(len(json_dict['channels']), 10) + + self.should_check_equality = False + return self.do_request('metadata/channels?sort_by=health', expected_code=200).addCallback(on_response) + def test_get_channels_invalid_sort(self): """ Test whether we can query some channels in the database with the REST API and an invalid sort parameter diff --git a/Tribler/Test/Core/Upgrade/test_db72_to_pony.py b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py index fae253972f2..6a5f4aef396 100644 --- a/Tribler/Test/Core/Upgrade/test_db72_to_pony.py +++ b/Tribler/Test/Core/Upgrade/test_db72_to_pony.py @@ -53,11 +53,12 @@ def test_convert_personal_channel(self): self.assertTrue(my_channel.has_valid_signature()) self.assertEqual(self.m.personal_channel_title[:200], my_channel.title) + @db_session def test_convert_all_channels(self): self.m.initialize() self.m.convert_discovered_torrents() self.m.convert_discovered_channels() - chans = self.mds.ChannelMetadata.get_channels() + chans = self.mds.ChannelMetadata.get_entries() self.assertEqual(len(chans[0]), 2) for c in chans[0]: diff --git a/Tribler/Test/data/linux_torrents/nested_dir/corrupt_torrent.torrent b/Tribler/Test/data/linux_torrents/nested_dir/corrupt_torrent.torrent new file mode 100644 index 00000000000..da78bf233fa --- /dev/null +++ b/Tribler/Test/data/linux_torrents/nested_dir/corrupt_torrent.torrent @@ -0,0 +1 @@ +This is not a valid torrent file. diff --git a/Tribler/Test/data/linux_torrents/nested_dir/ubuntu-15.04-desktop-amd64.iso.torrent b/Tribler/Test/data/linux_torrents/nested_dir/ubuntu-15.04-desktop-amd64.iso.torrent new file mode 100644 index 0000000000000000000000000000000000000000..cf47669abb58241d0639d2ebe7a23cbe36bb02bf GIT binary patch literal 44258 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 literal 0 HcmV?d00001 diff --git a/TriblerGUI/event_request_manager.py b/TriblerGUI/event_request_manager.py index a202d3fa0f3..33987651d72 100644 --- a/TriblerGUI/event_request_manager.py +++ b/TriblerGUI/event_request_manager.py @@ -13,6 +13,7 @@ class EventRequestManager(QNetworkAccessManager): The EventRequestManager class handles the events connection over which important events in Tribler are pushed. """ + torrent_info_updated = pyqtSignal(object) received_search_result_channel = pyqtSignal(object) received_search_result_torrent = pyqtSignal(object) tribler_started = pyqtSignal() @@ -80,7 +81,9 @@ def on_read_data(self): if len(received_events) > 100: # Only buffer the last 100 events received_events.pop() - if json_dict["type"] == "tribler_started" and not self.emitted_tribler_started: + if json_dict["type"] == "torrent_info_updated": + self.torrent_info_updated.emit(json_dict["event"]) + elif json_dict["type"] == "tribler_started" and not self.emitted_tribler_started: self.tribler_started.emit() self.emitted_tribler_started = True elif json_dict["type"] == "new_version_available": diff --git a/TriblerGUI/widgets/channelpage.py b/TriblerGUI/widgets/channelpage.py index 9cd76e218d6..f00412053e2 100644 --- a/TriblerGUI/widgets/channelpage.py +++ b/TriblerGUI/widgets/channelpage.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +from PyQt5.QtCore import Qt from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget @@ -24,6 +25,7 @@ def initialize_channel_page(self, gui_settings): self.gui_settings = gui_settings self.model = TorrentsContentModel(hide_xxx=get_gui_setting(self.gui_settings, "family_filter", True, is_bool=True) if self.gui_settings else True) + self.window().core_manager.events_manager.torrent_info_updated.connect(self.model.update_torrent_info) self.controller = TorrentsTableViewController(self.model, self.window().channel_page_container, None, self.window().channel_torrents_filter_input) diff --git a/TriblerGUI/widgets/tablecontentdelegate.py b/TriblerGUI/widgets/tablecontentdelegate.py index f74f4764359..58c6db09b54 100644 --- a/TriblerGUI/widgets/tablecontentdelegate.py +++ b/TriblerGUI/widgets/tablecontentdelegate.py @@ -474,7 +474,7 @@ def draw_text(self, painter, rect, text, color=QColor("#B5B5B5"), font=None, ali def paint(self, painter, rect, index): data_item = index.model().data_items[index.row()] - if u'health' not in data_item: + if u'health' not in data_item or data_item[u'health'] == "updated": data_item[u'health'] = get_health(data_item['num_seeders'], data_item['num_leechers'], data_item['last_tracker_check']) diff --git a/TriblerGUI/widgets/tablecontentmodel.py b/TriblerGUI/widgets/tablecontentmodel.py index 354977323c1..85246f87d12 100644 --- a/TriblerGUI/widgets/tablecontentmodel.py +++ b/TriblerGUI/widgets/tablecontentmodel.py @@ -14,13 +14,13 @@ class RemoteTableModel(QAbstractTableModel): It is specifically designed to fetch data from a remote data source, i.e. over a RESTful API. """ on_sort = pyqtSignal(str, bool) - columns = [] def __init__(self, parent=None): super(RemoteTableModel, self).__init__(parent) self.data_items = [] self.item_load_batch = 50 self.total_items = 0 # The total number of items without pagination + self.infohashes = {} @abstractmethod def _get_remote_data(self, start, end, **kwargs): @@ -41,11 +41,11 @@ def sort(self, column, order): self.on_sort.emit(self.columns[column], bool(order)) def add_items(self, new_data_items): + if not new_data_items: + return # If we want to block the signal like itemChanged, we must use QSignalBlocker object old_end = self.rowCount() new_end = self.rowCount() + len(new_data_items) - if old_end == new_end: - return self.beginInsertRows(QModelIndex(), old_end, new_end - 1) self.data_items.extend(new_data_items) self.endInsertRows() @@ -90,6 +90,25 @@ def data(self, index, role): return self.column_display_filters.get(column, str(data))(data) \ if column in self.column_display_filters else data + def add_items(self, new_data_items): + super(TriblerContentModel, self).add_items(new_data_items) + # Build reverse mapping from infohashes to rows + items_len = len(self.data_items) + new_items_len = len(new_data_items) + for i, item in enumerate(new_data_items): + if "infohash" in item: + self.infohashes[item["infohash"]] = items_len - new_items_len + i + + def reset(self): + self.infohashes.clear() + super(TriblerContentModel, self).reset() + + def update_torrent_info(self, update_dict): + row = self.infohashes.get(update_dict["infohash"]) + if row: + self.data_items[row].update(**update_dict) + self.dataChanged.emit(self.index(row, 0), self.index(row, len(self.columns)), []) + class SearchResultsContentModel(TriblerContentModel): """ diff --git a/TriblerGUI/widgets/torrentdetailstabwidget.py b/TriblerGUI/widgets/torrentdetailstabwidget.py index 6ff282a2579..d61cccced8c 100644 --- a/TriblerGUI/widgets/torrentdetailstabwidget.py +++ b/TriblerGUI/widgets/torrentdetailstabwidget.py @@ -64,7 +64,8 @@ def on_torrent_info(self, torrent_info): self.health_request_mgr.cancel_request() self.is_health_checking = False - self.update_health_label(torrent_info["torrent"]['num_seeders'], torrent_info["torrent"]['num_leechers'], + self.update_health_label(torrent_info["torrent"]['num_seeders'], + torrent_info["torrent"]['num_leechers'], torrent_info["torrent"]['last_tracker_check']) # If we do not have the health of this torrent, query it @@ -135,9 +136,11 @@ def on_cancel_health_check(): self.torrent_detail_health_label.setText("Checking...") self.health_request_mgr = TriblerRequestManager() - self.health_request_mgr.perform_request("metadata/torrents/%s/health?timeout=%s&refresh=%d" % - (infohash, timeout, 1), - self.on_health_response, capture_errors=False, priority="LOW", + self.health_request_mgr.perform_request("metadata/torrents/%s/health" % infohash, + self.on_health_response, + url_params={"nowait": True, + "refresh": True}, + capture_errors=False, priority="LOW", on_cancel=on_cancel_health_check) def on_health_response(self, response): @@ -148,6 +151,8 @@ def on_health_response(self, response): self.update_torrent_health(0, 0) # Just set the health to 0 seeders, 0 leechers return + if 'checking' in response: + return for _, status in response['health'].items(): if 'error' in status: continue # Timeout or invalid status From 5b8ca1c860308bea5ab11fd57249d8ae57d1e137 Mon Sep 17 00:00:00 2001 From: "V.G. Bulavintsev" Date: Tue, 19 Feb 2019 13:11:49 +0100 Subject: [PATCH 34/38] Search endpoint refactor --- .../OrmBindings/torrent_metadata.py | 7 +- .../Modules/MetadataStore/serialization.py | 6 -- .../Core/Modules/restapi/metadata_endpoint.py | 64 +++++--------- .../Modules/restapi/mychannel_endpoint.py | 1 + .../Core/Modules/restapi/search_endpoint.py | 79 ++++++------------ .../Modules/MetadataStore/test_metadata.py | 6 ++ .../Modules/RestApi/test_metadata_endpoint.py | 7 ++ .../Modules/RestApi/test_search_endpoint.py | 18 ++-- Tribler/Test/data/bak_new_tribler.sdb.tar | Bin 0 -> 10577920 bytes TriblerGUI/widgets/lazytableview.py | 4 +- TriblerGUI/widgets/triblertablecontrollers.py | 4 +- 11 files changed, 82 insertions(+), 114 deletions(-) create mode 100644 Tribler/Test/data/bak_new_tribler.sdb.tar diff --git a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py index eea68cefec5..32889303cb1 100644 --- a/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py +++ b/Tribler/Core/Modules/MetadataStore/OrmBindings/torrent_metadata.py @@ -138,8 +138,11 @@ def get_entries(cls, first=None, last=None, metadata_type=REGULAR_TORRENT, chann """ pony_query = cls.get_entries_query(**kwargs) - # We only want torrents, not channel torrents - pony_query = pony_query.where(metadata_type=metadata_type) + if type(metadata_type) == list: + pony_query = pony_query.where(lambda g: g.metadata_type in metadata_type) + else: + pony_query = pony_query.where(metadata_type=metadata_type) + if exclude_deleted: pony_query = pony_query.where(lambda g: g.status != TODELETE) if hide_xxx: diff --git a/Tribler/Core/Modules/MetadataStore/serialization.py b/Tribler/Core/Modules/MetadataStore/serialization.py index af19c8bba06..17dcaaf91b4 100644 --- a/Tribler/Core/Modules/MetadataStore/serialization.py +++ b/Tribler/Core/Modules/MetadataStore/serialization.py @@ -105,12 +105,6 @@ def __init__(self, metadata_type, reserved_flags, public_key, **kwargs): else: raise InvalidSignatureException("Tried to create payload without signature") - def has_valid_signature(self): - sig_data = default_serializer.pack_multiple(self.to_pack_list())[0] - return default_eccrypto.is_valid_signature( - default_eccrypto.key_from_public_bin(b"LibNaCLPK:" + self.public_key), sig_data, - self.signature) - def to_pack_list(self): data = [('H', self.metadata_type), ('H', self.reserved_flags), diff --git a/Tribler/Core/Modules/restapi/metadata_endpoint.py b/Tribler/Core/Modules/restapi/metadata_endpoint.py index 392c127f510..4bd9fdd3ea8 100644 --- a/Tribler/Core/Modules/restapi/metadata_endpoint.py +++ b/Tribler/Core/Modules/restapi/metadata_endpoint.py @@ -14,6 +14,10 @@ class BaseMetadataEndpoint(resource.Resource): + def __init__(self, session): + resource.Resource.__init__(self) + self.session = session + @staticmethod def sanitize_parameters(parameters): """ @@ -22,7 +26,7 @@ def sanitize_parameters(parameters): sanitized = { "first": 1 if 'first' not in parameters else int(parameters['first'][0]), "last": 50 if 'last' not in parameters else int(parameters['last'][0]), - "sort_by": None if 'sort_by' not in parameters else MetadataEndpoint.convert_sort_param_to_pony_col( + "sort_by": None if 'sort_by' not in parameters else BaseMetadataEndpoint.convert_sort_param_to_pony_col( parameters['sort_by'][0]), "sort_asc": True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])), "query_filter": None if 'filter' not in parameters else cast_to_unicode_utf8(parameters['filter'][0]), @@ -30,20 +34,6 @@ def sanitize_parameters(parameters): return sanitized - -class MetadataEndpoint(BaseMetadataEndpoint): - - def __init__(self, session): - BaseMetadataEndpoint.__init__(self) - - child_handler_dict = { - "channels": ChannelsEndpoint, - "torrents": TorrentsEndpoint - } - - for path, child_cls in child_handler_dict.items(): - self.putChild(path, child_cls(session)) - @staticmethod def convert_sort_param_to_pony_col(sort_param): """ @@ -62,17 +52,24 @@ def convert_sort_param_to_pony_col(sort_param): u'health': 'HEALTH' } - if sort_param not in json2pony_columns: - return None - return json2pony_columns[sort_param] + return json2pony_columns[sort_param] if sort_param in json2pony_columns else None -class BaseChannelsEndpoint(resource.Resource): +class MetadataEndpoint(resource.Resource): def __init__(self, session): resource.Resource.__init__(self) - self.session = session + child_handler_dict = { + "channels": ChannelsEndpoint, + "torrents": TorrentsEndpoint + } + + for path, child_cls in child_handler_dict.items(): + self.putChild(path, child_cls(session)) + + +class BaseChannelsEndpoint(BaseMetadataEndpoint): @staticmethod def sanitize_parameters(parameters): """ @@ -153,27 +150,10 @@ def render_POST(self, request): return json.dumps({"success": True, "subscribed": to_subscribe}) -class BaseTorrentsEndpoint(resource.Resource): - - def __init__(self, session): - resource.Resource.__init__(self) - self.session = session - - @staticmethod - def sanitize_parameters(parameters): - """ - Sanitize the parameters for a request that fetches channels. - """ - sanitized = BaseMetadataEndpoint.sanitize_parameters(parameters) - if 'channel' in parameters: - sanitized['channel'] = unhexlify(parameters['channel'][0]) - return sanitized - - -class SpecificChannelTorrentsEndpoint(BaseTorrentsEndpoint): +class SpecificChannelTorrentsEndpoint(BaseMetadataEndpoint): def __init__(self, session, channel_pk): - BaseTorrentsEndpoint.__init__(self, session) + BaseMetadataEndpoint.__init__(self, session) self.channel_pk = channel_pk def render_GET(self, request): @@ -192,10 +172,10 @@ def render_GET(self, request): }) -class TorrentsEndpoint(BaseTorrentsEndpoint): +class TorrentsEndpoint(BaseMetadataEndpoint): def __init__(self, session): - BaseTorrentsEndpoint.__init__(self, session) + BaseMetadataEndpoint.__init__(self, session) self.putChild("random", TorrentsRandomEndpoint(session)) def getChild(self, path, request): @@ -227,7 +207,7 @@ def render_GET(self, request): return json.dumps({"torrent": torrent_dict}) -class TorrentsRandomEndpoint(BaseTorrentsEndpoint): +class TorrentsRandomEndpoint(BaseMetadataEndpoint): def render_GET(self, request): limit_torrents = 10 diff --git a/Tribler/Core/Modules/restapi/mychannel_endpoint.py b/Tribler/Core/Modules/restapi/mychannel_endpoint.py index 81d89a18c43..e165615c580 100644 --- a/Tribler/Core/Modules/restapi/mychannel_endpoint.py +++ b/Tribler/Core/Modules/restapi/mychannel_endpoint.py @@ -255,6 +255,7 @@ class MyChannelSpecificTorrentEndpoint(BaseMyChannelEndpoint): def __init__(self, session, infohash): BaseMyChannelEndpoint.__init__(self, session) self.infohash = unhexlify(infohash) + @db_session def render_PATCH(self, request): parameters = http.parse_qs(request.content.read(), 1) diff --git a/Tribler/Core/Modules/restapi/search_endpoint.py b/Tribler/Core/Modules/restapi/search_endpoint.py index 5758ded8933..a15062f4bdd 100644 --- a/Tribler/Core/Modules/restapi/search_endpoint.py +++ b/Tribler/Core/Modules/restapi/search_endpoint.py @@ -2,16 +2,16 @@ import logging -from pony import orm from pony.orm import db_session from twisted.web import http, resource import Tribler.Core.Utilities.json_util as json from Tribler.Core.Modules.MetadataStore.serialization import REGULAR_TORRENT, CHANNEL_TORRENT +from Tribler.Core.Modules.restapi.metadata_endpoint import BaseMetadataEndpoint from Tribler.util import cast_to_unicode_utf8 -class SearchEndpoint(resource.Resource): +class SearchEndpoint(BaseMetadataEndpoint): """ This endpoint is responsible for searching in channels and torrents present in the local Tribler database. It also fires a remote search in the Dispersy communities. @@ -26,39 +26,17 @@ def __init__(self, session): self.putChild("completions", SearchCompletionsEndpoint(session)) @staticmethod - def convert_sort_param_to_pony_col(sort_param): - """ - Convert an incoming sort parameter to a pony column in the database. - :return a string with the right column. None if there exists no value for the given key. - """ - json2pony_columns = { - u'category': "tags", - u'id': "rowid", - u'name': "title", - u'health': "health", - } - - if sort_param not in json2pony_columns: - return None - return json2pony_columns[sort_param] + def convert_datatype_param_to_search_scope(data_type): + return {'': [REGULAR_TORRENT, CHANNEL_TORRENT], + "channel": CHANNEL_TORRENT, + "torrent": REGULAR_TORRENT}.get(data_type) @staticmethod def sanitize_parameters(parameters): - """ - Sanitize the parameters and check whether they exist - """ - #TODO: unify this wit MetadataEndpoint - first = 1 if 'first' not in parameters else int(parameters['first'][0]) - last = 50 if 'last' not in parameters else int(parameters['last'][0]) - sort_by = None if 'sort_by' not in parameters else parameters['sort_by'][0] - sort_asc = True if 'sort_asc' not in parameters else bool(int(parameters['sort_asc'][0])) - data_type = None if 'type' not in parameters else parameters['type'][0] - hide_xxx = False if 'hide_xxx' not in parameters else bool(int(parameters['hide_xxx'][0]) > 0) - - if sort_by: - sort_by = SearchEndpoint.convert_sort_param_to_pony_col(sort_by) - - return first, last, sort_by, sort_asc, data_type, hide_xxx + sanitized = BaseMetadataEndpoint.sanitize_parameters(parameters) + sanitized['metadata_type'] = SearchEndpoint.convert_datatype_param_to_search_scope( + parameters['metadata_type'][0] if 'metadata_type' in parameters else '') + return sanitized def render_GET(self, request): """ @@ -105,37 +83,28 @@ def render_GET(self, request): "chant_dirty":false } """ - if 'q' not in request.args or not request.args['q'] or not request.args['q'][0]: + + sanitized = SearchEndpoint.sanitize_parameters(request.args) + + if not sanitized["query_filter"]: + request.setResponseCode(http.BAD_REQUEST) + return json.dumps({"error": "filter parameter missing"}) + + if not sanitized["metadata_type"]: request.setResponseCode(http.BAD_REQUEST) - return json.dumps({"error": "q parameter missing"}) - - first, last, sort_by, sort_asc, data_type, hide_xxx = SearchEndpoint.sanitize_parameters(request.args) - query = cast_to_unicode_utf8(request.args['q'][0]) - - if not data_type: - search_scope = lambda g: (g.metadata_type == REGULAR_TORRENT or g.metadata_type == CHANNEL_TORRENT) - elif data_type == 'channel': - search_scope = lambda g: g.metadata_type == CHANNEL_TORRENT - elif data_type == 'torrent': - search_scope = lambda g: g.metadata_type == REGULAR_TORRENT - else: return json.dumps({"error": "Trying to query for unknown type of metadata"}) with db_session: - pony_query= self.session.lm.mds.TorrentMetadata.get_entries_query( - sort_by, sort_asc, query_filter=query).where(search_scope) - if hide_xxx: - pony_query = pony_query.where(lambda g: g.xxx == 0) - total = orm.count(pony_query) + pony_query, total = self.session.lm.mds.TorrentMetadata.get_entries(**sanitized) search_results = [(dict(type={REGULAR_TORRENT: 'torrent', CHANNEL_TORRENT: 'channel'}[r.metadata_type], **(r.to_simple_dict()))) for r in pony_query] return json.dumps({ - "results": search_results[first - 1:last], - "first": first, - "last": last, - "sort_by": sort_by, - "sort_asc": sort_asc, + "results": search_results, + "first": sanitized["first"], + "last": sanitized["last"], + "sort_by": sanitized["sort_by"], + "sort_asc": sanitized["sort_asc"], "total": total }) diff --git a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py index 1f085864d6e..76ee0dce7a6 100644 --- a/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py +++ b/Tribler/Test/Core/Modules/MetadataStore/test_metadata.py @@ -50,11 +50,17 @@ def test_serialization(self): serialized2 = metadata2.serialized() self.assertEqual(serialized1, serialized2) + # Test no signature exception + metadata2_dict = metadata2.to_dict() + metadata2_dict.pop("signature") + self.assertRaises(InvalidSignatureException, ChannelNodePayload, **metadata2_dict) + serialized3 = serialized2[:-5] + "\xee" * 5 self.assertRaises(InvalidSignatureException, ChannelNodePayload.from_signed_blob, serialized3) # Test bypass signature check ChannelNodePayload.from_signed_blob(serialized3, check_signature=False) + @db_session def test_key_mismatch_exception(self): mismatched_key = default_eccrypto.generate_key(u"curve25519") diff --git a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py index 9420e62a4aa..6ce5835baa4 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_metadata_endpoint.py @@ -277,3 +277,10 @@ def get_metainfo(infohash, callback, **_): self.udp_tracker.start() self.http_tracker.start() yield self.do_request(url, expected_code=200, request_type='GET').addCallback(verify_response_no_trackers) + + def verify_response_nowait(response): + json_response = json.loads(response) + self.assertDictEqual(json_response, {u'checking': u'1'}) + + yield self.do_request(url + '&nowait=1', expected_code=200, request_type='GET').addCallback( + verify_response_nowait) diff --git a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py index 0c0f3a698eb..16ae0f8d7d9 100644 --- a/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py +++ b/Tribler/Test/Core/Modules/RestApi/test_search_endpoint.py @@ -26,6 +26,14 @@ def test_search_no_query(self): self.should_check_equality = False return self.do_request('search', expected_code=400) + @trial_timeout(10) + def test_search_wrong_mdtype(self): + """ + Testing whether the API returns an error 400 if wrong metadata type is passed in the query + """ + self.should_check_equality = False + return self.do_request('search?filter=bla&metadata_type=ddd', expected_code=400) + @trial_timeout(10) @inlineCallbacks def test_search(self): @@ -44,23 +52,23 @@ def test_search(self): self.should_check_equality = False - result = yield self.do_request('search?q=needle', expected_code=200) + result = yield self.do_request('search?filter=needle', expected_code=200) parsed = json.loads(result) self.assertEqual(len(parsed["results"]), 1) - result = yield self.do_request('search?q=hay', expected_code=200) + result = yield self.do_request('search?filter=hay', expected_code=200) parsed = json.loads(result) self.assertEqual(len(parsed["results"]), 50) - result = yield self.do_request('search?q=test&type=channel', expected_code=200) + result = yield self.do_request('search?filter=test&type=channel', expected_code=200) parsed = json.loads(result) self.assertEqual(len(parsed["results"]), 1) - result = yield self.do_request('search?q=needle&type=torrent', expected_code=200) + result = yield self.do_request('search?filter=needle&type=torrent', expected_code=200) parsed = json.loads(result) self.assertEqual(parsed["results"][0][u'name'], 'needle') - result = yield self.do_request('search?q=needle&sort_by=name', expected_code=200) + result = yield self.do_request('search?filter=needle&sort_by=name', expected_code=200) parsed = json.loads(result) self.assertEqual(len(parsed["results"]), 1) diff --git a/Tribler/Test/data/bak_new_tribler.sdb.tar b/Tribler/Test/data/bak_new_tribler.sdb.tar new file mode 100644 index 0000000000000000000000000000000000000000..2ea7c866c26f6875b645a4666f17b004ac8aa641 GIT binary patch literal 10577920 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 z3jV0zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;ExLa zsNjzZ{;1%O3jV0zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;ExLasNjzZ{;1%O3jV0z zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;ExLasNjzZ z{;1%O3jV0zj|%>%;ExLasNjzZ{;1%O3jV0zj|%>%;Ew|SDBzC*{wUy&0{$rAj{^QE z;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy& z0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|S zDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rA zj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC* z{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE z;Ew|SDBzC*{wUy&0{$rAj{^QE;Ew|SDBzC*{wUy&0{$rAj{^QE;ExRc$l#9*{>b2u z4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc z$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UA zj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9* z{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E z;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u z4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;ExRc$l#9*{>b2u4F1UAj|~3E;Ex3U zNZ^kI{z%}D1pY|ij|Bcm;Ex3UNZ^kI{z%}D1pY|ij|Bcm;Ex3UNZ^kI{z%}D1pY|i zj|Bcm;Ex3UNZ^kI{z%}D1pY|ij|Bcm;Ex3UNZ^kI{z%}D1pY|ij|Bcm;Ex3UNZ^kI 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 z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ z1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009IL zKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~ z0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY** z5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0 z009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{1Q0*~0R#|0009ILKmY**5I_I{ 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

`GZAB!BEmNhBnH?20K8l0SCE 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

& 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 ju%2 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

eXw`*hE^j!zB7A#HP|eBQ}jzZ8C#ChuBP7wa+a2 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ΞcY=$?=rdMLCj^g`%x=)=&Lp`Sum!WF`i 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̕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

XbVG&M9Q^jK&~Xhp~jeHi*Iv_JG?=(ljius@s% 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

5`~BO| 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