Cleanup torrentmanger code
* Remove legacy/old state file code * Passes flake8 cleanly * Most pylint messages dealt with * Code now uses >=Python 2.6 'with' and 'as' statements
This commit is contained in:
parent
77cb794e4d
commit
e3f3b6d751
|
@ -1,143 +0,0 @@
|
|||
#
|
||||
# oldstateupgrader.py
|
||||
#
|
||||
# Copyright (C) 2008 Andrew Resch <andrewresch@gmail.com>
|
||||
#
|
||||
# Deluge is free software.
|
||||
#
|
||||
# You may redistribute it and/or modify it under the terms of the
|
||||
# GNU General Public License, as published by the Free Software
|
||||
# Foundation; either version 3 of the License, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# deluge is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
# See the GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with deluge. If not, write to:
|
||||
# The Free Software Foundation, Inc.,
|
||||
# 51 Franklin Street, Fifth Floor
|
||||
# Boston, MA 02110-1301, USA.
|
||||
#
|
||||
# In addition, as a special exception, the copyright holders give
|
||||
# permission to link the code of portions of this program with the OpenSSL
|
||||
# library.
|
||||
# You must obey the GNU General Public License in all respects for all of
|
||||
# the code used other than OpenSSL. If you modify file(s) with this
|
||||
# exception, you may extend this exception to your version of the file(s),
|
||||
# but you are not obligated to do so. If you do not wish to do so, delete
|
||||
# this exception statement from your version. If you delete this exception
|
||||
# statement from all source files in the program, then also delete it here.
|
||||
#
|
||||
#
|
||||
|
||||
|
||||
import os
|
||||
import os.path
|
||||
import pickle
|
||||
import cPickle
|
||||
import shutil
|
||||
import logging
|
||||
|
||||
from deluge._libtorrent import lt
|
||||
|
||||
from deluge.configmanager import ConfigManager, get_config_dir
|
||||
import deluge.core.torrentmanager
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
#start : http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/286203
|
||||
def makeFakeClass(module, name):
|
||||
class FakeThing(object):
|
||||
pass
|
||||
FakeThing.__name__ = name
|
||||
FakeThing.__module__ = '(fake)' + module
|
||||
return FakeThing
|
||||
|
||||
class PickleUpgrader(pickle.Unpickler):
|
||||
def find_class(self, module, cname):
|
||||
# Pickle tries to load a couple things like copy_reg and
|
||||
# __builtin__.object even though a pickle file doesn't
|
||||
# explicitly reference them (afaict): allow them to be loaded
|
||||
# normally.
|
||||
if module in ('copy_reg', '__builtin__'):
|
||||
thing = pickle.Unpickler.find_class(self, module, cname)
|
||||
return thing
|
||||
return makeFakeClass(module, cname)
|
||||
# end: http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/286203
|
||||
|
||||
class OldStateUpgrader:
|
||||
def __init__(self):
|
||||
self.config = ConfigManager("core.conf")
|
||||
self.state05_location = os.path.join(get_config_dir(), "persistent.state")
|
||||
self.state10_location = os.path.join(get_config_dir(), "state", "torrents.state")
|
||||
if os.path.exists(self.state05_location) and not os.path.exists(self.state10_location):
|
||||
# If the 0.5 state file exists and the 1.0 doesn't, then let's upgrade it
|
||||
self.upgrade05()
|
||||
|
||||
def upgrade05(self):
|
||||
try:
|
||||
state = PickleUpgrader(open(self.state05_location, "rb")).load()
|
||||
except Exception, e:
|
||||
log.debug("Unable to open 0.5 state file: %s", e)
|
||||
return
|
||||
|
||||
# Check to see if we can upgrade this file
|
||||
if type(state).__name__ == 'list':
|
||||
log.warning("0.5 state file is too old to upgrade")
|
||||
return
|
||||
|
||||
new_state = deluge.core.torrentmanager.TorrentManagerState()
|
||||
for ti, uid in state.torrents.items():
|
||||
torrent_path = os.path.join(get_config_dir(), "torrentfiles", ti.filename)
|
||||
try:
|
||||
torrent_info = None
|
||||
log.debug("Attempting to create torrent_info from %s", torrent_path)
|
||||
_file = open(torrent_path, "rb")
|
||||
torrent_info = lt.torrent_info(lt.bdecode(_file.read()))
|
||||
_file.close()
|
||||
except (IOError, RuntimeError), e:
|
||||
log.warning("Unable to open %s: %s", torrent_path, e)
|
||||
|
||||
# Copy the torrent file to the new location
|
||||
import shutil
|
||||
shutil.copyfile(torrent_path, os.path.join(get_config_dir(), "state", str(torrent_info.info_hash()) + ".torrent"))
|
||||
|
||||
# Set the file prioritiy property if not already there
|
||||
if not hasattr(ti, "priorities"):
|
||||
ti.priorities = [1] * torrent_info.num_files()
|
||||
|
||||
# Create the new TorrentState object
|
||||
new_torrent = deluge.core.torrentmanager.TorrentState(
|
||||
torrent_id=str(torrent_info.info_hash()),
|
||||
filename=ti.filename,
|
||||
save_path=ti.save_dir,
|
||||
compact=ti.compact,
|
||||
paused=ti.user_paused,
|
||||
total_uploaded=ti.uploaded_memory,
|
||||
max_upload_speed=ti.upload_rate_limit,
|
||||
max_download_speed=ti.download_rate_limit,
|
||||
file_priorities=ti.priorities,
|
||||
queue=state.queue.index(ti)
|
||||
)
|
||||
# Append the object to the state list
|
||||
new_state.torrents.append(new_torrent)
|
||||
|
||||
# Now we need to write out the new state file
|
||||
try:
|
||||
log.debug("Saving torrent state file.")
|
||||
state_file = open(
|
||||
os.path.join(get_config_dir(), "state", "torrents.state"), "wb")
|
||||
cPickle.dump(new_state, state_file)
|
||||
state_file.close()
|
||||
except IOError, e:
|
||||
log.warning("Unable to save state file: %s", e)
|
||||
return
|
||||
|
||||
# Rename the persistent.state file
|
||||
try:
|
||||
os.rename(self.state05_location, self.state05_location + ".old")
|
||||
except Exception, e:
|
||||
log.debug("Unable to rename old persistent.state file! %s", e)
|
|
@ -49,48 +49,48 @@ from twisted.internet import reactor
|
|||
|
||||
from deluge._libtorrent import lt
|
||||
|
||||
from deluge.event import *
|
||||
from deluge.error import *
|
||||
from deluge.event import (TorrentAddedEvent, PreTorrentRemovedEvent, TorrentRemovedEvent,
|
||||
SessionStartedEvent, TorrentFinishedEvent, TorrentStateChangedEvent,
|
||||
TorrentResumedEvent, TorrentFileRenamedEvent, TorrentFileCompletedEvent)
|
||||
from deluge.error import InvalidTorrentError
|
||||
import deluge.component as component
|
||||
from deluge.configmanager import ConfigManager, get_config_dir
|
||||
from deluge.core.authmanager import AUTH_LEVEL_ADMIN
|
||||
from deluge.core.torrent import Torrent
|
||||
from deluge.core.torrent import TorrentOptions
|
||||
import deluge.core.oldstateupgrader
|
||||
from deluge.core.torrent import Torrent, TorrentOptions, sanitize_filepath
|
||||
from deluge.common import utf8_encoded, decode_string
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TorrentState:
|
||||
def __init__(self,
|
||||
torrent_id=None,
|
||||
filename=None,
|
||||
total_uploaded=0,
|
||||
trackers=None,
|
||||
compact=False,
|
||||
paused=False,
|
||||
save_path=None,
|
||||
max_connections=-1,
|
||||
max_upload_slots=-1,
|
||||
max_upload_speed=-1.0,
|
||||
max_download_speed=-1.0,
|
||||
prioritize_first_last=False,
|
||||
sequential_download=False,
|
||||
file_priorities=None,
|
||||
queue=None,
|
||||
auto_managed=True,
|
||||
is_finished=False,
|
||||
stop_ratio=2.00,
|
||||
stop_at_ratio=False,
|
||||
remove_at_ratio=False,
|
||||
move_completed=False,
|
||||
move_completed_path=None,
|
||||
magnet=None,
|
||||
time_added=-1,
|
||||
last_seen_complete=0,
|
||||
owner=None,
|
||||
shared=False
|
||||
):
|
||||
torrent_id=None,
|
||||
filename=None,
|
||||
total_uploaded=0,
|
||||
trackers=None,
|
||||
compact=False,
|
||||
paused=False,
|
||||
save_path=None,
|
||||
max_connections=-1,
|
||||
max_upload_slots=-1,
|
||||
max_upload_speed=-1.0,
|
||||
max_download_speed=-1.0,
|
||||
prioritize_first_last=False,
|
||||
sequential_download=False,
|
||||
file_priorities=None,
|
||||
queue=None,
|
||||
auto_managed=True,
|
||||
is_finished=False,
|
||||
stop_ratio=2.00,
|
||||
stop_at_ratio=False,
|
||||
remove_at_ratio=False,
|
||||
move_completed=False,
|
||||
move_completed_path=None,
|
||||
magnet=None,
|
||||
time_added=-1,
|
||||
last_seen_complete=0,
|
||||
owner=None,
|
||||
shared=False):
|
||||
self.torrent_id = torrent_id
|
||||
self.filename = filename
|
||||
self.total_uploaded = total_uploaded
|
||||
|
@ -121,10 +121,12 @@ class TorrentState:
|
|||
self.move_completed_path = move_completed_path
|
||||
self.shared = shared
|
||||
|
||||
|
||||
class TorrentManagerState:
|
||||
def __init__(self):
|
||||
self.torrents = []
|
||||
|
||||
|
||||
class TorrentManager(component.Component):
|
||||
"""
|
||||
TorrentManager contains a list of torrents in the current libtorrent
|
||||
|
@ -135,7 +137,7 @@ class TorrentManager(component.Component):
|
|||
def __init__(self):
|
||||
component.Component.__init__(self, "TorrentManager", interval=5,
|
||||
depend=["CorePluginManager", "AlertManager"])
|
||||
log.debug("TorrentManager init..")
|
||||
log.debug("TorrentManager init...")
|
||||
# Set the libtorrent session
|
||||
self.session = component.get("Core").session
|
||||
# Set the alertmanager
|
||||
|
@ -166,57 +168,44 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Register set functions
|
||||
self.config.register_set_function("max_connections_per_torrent",
|
||||
self.on_set_max_connections_per_torrent)
|
||||
self.on_set_max_connections_per_torrent)
|
||||
self.config.register_set_function("max_upload_slots_per_torrent",
|
||||
self.on_set_max_upload_slots_per_torrent)
|
||||
self.on_set_max_upload_slots_per_torrent)
|
||||
self.config.register_set_function("max_upload_speed_per_torrent",
|
||||
self.on_set_max_upload_speed_per_torrent)
|
||||
self.on_set_max_upload_speed_per_torrent)
|
||||
self.config.register_set_function("max_download_speed_per_torrent",
|
||||
self.on_set_max_download_speed_per_torrent)
|
||||
self.on_set_max_download_speed_per_torrent)
|
||||
|
||||
# Register alert functions
|
||||
self.alerts.register_handler("torrent_finished_alert",
|
||||
self.on_alert_torrent_finished)
|
||||
self.alerts.register_handler("torrent_paused_alert",
|
||||
self.on_alert_torrent_paused)
|
||||
self.alerts.register_handler("torrent_checked_alert",
|
||||
self.on_alert_torrent_checked)
|
||||
self.alerts.register_handler("tracker_reply_alert",
|
||||
self.on_alert_tracker_reply)
|
||||
self.alerts.register_handler("tracker_announce_alert",
|
||||
self.on_alert_tracker_announce)
|
||||
self.alerts.register_handler("tracker_warning_alert",
|
||||
self.on_alert_tracker_warning)
|
||||
self.alerts.register_handler("tracker_error_alert",
|
||||
self.on_alert_tracker_error)
|
||||
self.alerts.register_handler("storage_moved_alert",
|
||||
self.on_alert_storage_moved)
|
||||
self.alerts.register_handler("torrent_resumed_alert",
|
||||
self.on_alert_torrent_resumed)
|
||||
self.alerts.register_handler("state_changed_alert",
|
||||
self.on_alert_state_changed)
|
||||
self.alerts.register_handler("save_resume_data_alert",
|
||||
self.on_alert_save_resume_data)
|
||||
self.alerts.register_handler("torrent_finished_alert", self.on_alert_torrent_finished)
|
||||
self.alerts.register_handler("torrent_paused_alert", self.on_alert_torrent_paused)
|
||||
self.alerts.register_handler("torrent_checked_alert", self.on_alert_torrent_checked)
|
||||
self.alerts.register_handler("tracker_reply_alert", self.on_alert_tracker_reply)
|
||||
self.alerts.register_handler("tracker_announce_alert", self.on_alert_tracker_announce)
|
||||
self.alerts.register_handler("tracker_warning_alert", self.on_alert_tracker_warning)
|
||||
self.alerts.register_handler("tracker_error_alert", self.on_alert_tracker_error)
|
||||
self.alerts.register_handler("storage_moved_alert", self.on_alert_storage_moved)
|
||||
self.alerts.register_handler("torrent_resumed_alert", self.on_alert_torrent_resumed)
|
||||
self.alerts.register_handler("state_changed_alert", self.on_alert_state_changed)
|
||||
self.alerts.register_handler("save_resume_data_alert", self.on_alert_save_resume_data)
|
||||
self.alerts.register_handler("save_resume_data_failed_alert",
|
||||
self.on_alert_save_resume_data_failed)
|
||||
self.alerts.register_handler("file_renamed_alert",
|
||||
self.on_alert_file_renamed)
|
||||
self.alerts.register_handler("metadata_received_alert",
|
||||
self.on_alert_metadata_received)
|
||||
self.alerts.register_handler("file_error_alert",
|
||||
self.on_alert_file_error)
|
||||
self.alerts.register_handler("file_completed_alert",
|
||||
self.on_alert_file_completed)
|
||||
self.alerts.register_handler("state_update_alert",
|
||||
self.on_alert_state_update)
|
||||
self.on_alert_save_resume_data_failed)
|
||||
self.alerts.register_handler("file_renamed_alert", self.on_alert_file_renamed)
|
||||
self.alerts.register_handler("metadata_received_alert", self.on_alert_metadata_received)
|
||||
self.alerts.register_handler("file_error_alert", self.on_alert_file_error)
|
||||
self.alerts.register_handler("file_completed_alert", self.on_alert_file_completed)
|
||||
self.alerts.register_handler("state_update_alert", self.on_alert_state_update)
|
||||
|
||||
# Define timers
|
||||
self.save_state_timer = LoopingCall(self.save_state)
|
||||
self.save_resume_data_timer = LoopingCall(self.save_resume_data)
|
||||
self.save_all_resume_data_timer = LoopingCall(self.save_resume_data, self.torrents.keys())
|
||||
|
||||
def start(self):
|
||||
# Get the pluginmanager reference
|
||||
self.plugins = component.get("CorePluginManager")
|
||||
|
||||
# Check for old temp file to verify safe shutdown
|
||||
if os.path.isfile(self.temp_file):
|
||||
def archive_file(filename):
|
||||
"""Archives the file in 'archive' sub-directory with timestamp appended"""
|
||||
import datetime
|
||||
filepath = os.path.join(self.state_dir, filename)
|
||||
filepath_bak = filepath + ".bak"
|
||||
|
@ -241,19 +230,13 @@ class TorrentManager(component.Component):
|
|||
with file(self.temp_file, 'a'):
|
||||
os.utime(self.temp_file, None)
|
||||
|
||||
# Run the old state upgrader before loading state
|
||||
deluge.core.oldstateupgrader.OldStateUpgrader()
|
||||
|
||||
# Try to load the state from file
|
||||
self.load_state()
|
||||
|
||||
# Save the state periodically
|
||||
self.save_state_timer = LoopingCall(self.save_state)
|
||||
self.save_state_timer.start(200, False)
|
||||
self.save_resume_data_timer = LoopingCall(self.save_resume_data)
|
||||
self.save_resume_data_timer.start(190, False)
|
||||
# Force update for all resume data a bit less frequently
|
||||
self.save_all_resume_data_timer = LoopingCall(self.save_resume_data, self.torrents.keys())
|
||||
self.save_all_resume_data_timer.start(900, False)
|
||||
|
||||
def stop(self):
|
||||
|
@ -276,6 +259,7 @@ class TorrentManager(component.Component):
|
|||
self.torrents[key].prev_status_cleanup_loop.stop()
|
||||
|
||||
def remove_temp_file(result):
|
||||
"""Remove the temp_file to signify successfully saved state"""
|
||||
if result and os.path.isfile(self.temp_file):
|
||||
os.remove(self.temp_file)
|
||||
|
||||
|
@ -286,11 +270,10 @@ class TorrentManager(component.Component):
|
|||
def update(self):
|
||||
for torrent_id, torrent in self.torrents.items():
|
||||
if torrent.options["stop_at_ratio"] and torrent.state not in (
|
||||
"Checking", "Allocating", "Paused", "Queued"):
|
||||
# If the global setting is set, but the per-torrent isn't..
|
||||
"Checking", "Allocating", "Paused", "Queued"):
|
||||
# If the global setting is set, but the per-torrent isn't...
|
||||
# Just skip to the next torrent.
|
||||
# This is so that a user can turn-off the stop at ratio option
|
||||
# on a per-torrent basis
|
||||
# This is so that a user can turn-off the stop at ratio option on a per-torrent basis
|
||||
if not torrent.options["stop_at_ratio"]:
|
||||
continue
|
||||
if torrent.get_ratio() >= torrent.options["stop_ratio"] and torrent.is_finished:
|
||||
|
@ -313,45 +296,22 @@ class TorrentManager(component.Component):
|
|||
current_user = component.get("RPCServer").get_session_user()
|
||||
for torrent_id in torrent_ids[:]:
|
||||
torrent_status = self[torrent_id].get_status(["owner", "shared"])
|
||||
if torrent_status["owner"] != current_user and torrent_status["shared"] == False:
|
||||
if torrent_status["owner"] != current_user and not torrent_status["shared"]:
|
||||
torrent_ids.pop(torrent_ids.index(torrent_id))
|
||||
return torrent_ids
|
||||
|
||||
def get_torrent_info_from_file(self, filepath):
|
||||
"""Returns a torrent_info for the file specified or None"""
|
||||
torrent_info = None
|
||||
# Get the torrent data from the torrent file
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Attempting to extract torrent_info from %s", filepath)
|
||||
try:
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Attempting to create torrent_info from %s", filepath)
|
||||
_file = open(filepath, "rb")
|
||||
torrent_info = lt.torrent_info(lt.bdecode(_file.read()))
|
||||
_file.close()
|
||||
except (IOError, RuntimeError), e:
|
||||
log.warning("Unable to open %s: %s", filepath, e)
|
||||
|
||||
return torrent_info
|
||||
|
||||
def legacy_get_resume_data_from_file(self, torrent_id):
|
||||
"""Returns an entry with the resume data or None"""
|
||||
fastresume = ""
|
||||
try:
|
||||
_file = open(os.path.join(self.state_dir, torrent_id + ".fastresume"), "rb")
|
||||
fastresume = _file.read()
|
||||
_file.close()
|
||||
except IOError, e:
|
||||
log.debug("Unable to load .fastresume: %s", e)
|
||||
|
||||
return str(fastresume)
|
||||
|
||||
def legacy_delete_resume_data(self, torrent_id):
|
||||
"""Deletes the .fastresume file"""
|
||||
path = os.path.join(self.state_dir, torrent_id + ".fastresume")
|
||||
log.debug("Deleting fastresume file: %s", path)
|
||||
try:
|
||||
os.remove(path)
|
||||
except Exception, e:
|
||||
log.warning("Unable to delete the fastresume file: %s", e)
|
||||
with open(filepath, "rb") as _file:
|
||||
torrent_info = lt.torrent_info(lt.bdecode(_file.read()))
|
||||
except (IOError, RuntimeError) as ex:
|
||||
log.warning("Unable to open torrent file %s: %s", filepath, ex)
|
||||
else:
|
||||
return torrent_info
|
||||
|
||||
def add(self, torrent_info=None, state=None, options=None, save_state=True,
|
||||
filedump=None, filename=None, magnet=None, resume_data=None, owner=None):
|
||||
|
@ -365,9 +325,9 @@ class TorrentManager(component.Component):
|
|||
if filedump is not None:
|
||||
try:
|
||||
torrent_info = lt.torrent_info(lt.bdecode(filedump))
|
||||
except Exception, e:
|
||||
log.error("Unable to decode torrent file!: %s", e)
|
||||
# XXX: Probably should raise an exception here..
|
||||
except RuntimeError as ex:
|
||||
log.error("Unable to decode torrent file!: %s", ex)
|
||||
# XXX: Probably should raise an exception here...
|
||||
return
|
||||
|
||||
if torrent_info is None and state:
|
||||
|
@ -395,23 +355,16 @@ class TorrentManager(component.Component):
|
|||
options["shared"] = state.shared
|
||||
owner = state.owner
|
||||
|
||||
ti = self.get_torrent_info_from_file(
|
||||
os.path.join(get_config_dir(),
|
||||
"state", state.torrent_id + ".torrent"))
|
||||
if ti:
|
||||
add_torrent_params["ti"] = ti
|
||||
torrent_info = self.get_torrent_info_from_file(
|
||||
os.path.join(self.state_dir, state.torrent_id + ".torrent"))
|
||||
if torrent_info:
|
||||
add_torrent_params["ti"] = torrent_info
|
||||
elif state.magnet:
|
||||
magnet = state.magnet
|
||||
else:
|
||||
log.error("Unable to add torrent!")
|
||||
return
|
||||
|
||||
# Handle legacy case with storing resume data in individual files
|
||||
# for each torrent
|
||||
if resume_data is None:
|
||||
resume_data = self.legacy_get_resume_data_from_file(state.torrent_id)
|
||||
self.legacy_delete_resume_data(state.torrent_id)
|
||||
|
||||
if resume_data:
|
||||
add_torrent_params["resume_data"] = resume_data
|
||||
else:
|
||||
|
@ -430,7 +383,7 @@ class TorrentManager(component.Component):
|
|||
|
||||
torrent_trackers = {}
|
||||
tracker_list = []
|
||||
for tracker in self[add_torrent_id].get_status(["trackers"])["trackers"]:
|
||||
for tracker in self[add_torrent_id].get_status(["trackers"])["trackers"]:
|
||||
torrent_trackers[(tracker["url"])] = tracker
|
||||
tracker_list.append(tracker)
|
||||
|
||||
|
@ -445,12 +398,12 @@ class TorrentManager(component.Component):
|
|||
return
|
||||
|
||||
# Check if options is None and load defaults
|
||||
if options == None:
|
||||
if options is None:
|
||||
options = TorrentOptions()
|
||||
else:
|
||||
o = TorrentOptions()
|
||||
o.update(options)
|
||||
options = o
|
||||
_options = TorrentOptions()
|
||||
_options.update(options)
|
||||
options = _options
|
||||
|
||||
# Check for renamed files and if so, rename them in the torrent_info
|
||||
# before adding to the session.
|
||||
|
@ -460,7 +413,7 @@ class TorrentManager(component.Component):
|
|||
fname = unicode(fname, "utf-8")
|
||||
except TypeError:
|
||||
pass
|
||||
fname = deluge.core.torrent.sanitize_filepath(fname)
|
||||
fname = sanitize_filepath(fname)
|
||||
log.debug("renaming file index %s to %s", index, fname)
|
||||
try:
|
||||
torrent_info.rename_file(index, fname)
|
||||
|
@ -501,8 +454,8 @@ class TorrentManager(component.Component):
|
|||
handle = lt.add_magnet_uri(self.session, utf8_encoded(magnet), add_torrent_params)
|
||||
else:
|
||||
handle = self.session.add_torrent(add_torrent_params)
|
||||
except RuntimeError, e:
|
||||
log.warning("Error adding torrent: %s", e)
|
||||
except RuntimeError as ex:
|
||||
log.warning("Error adding torrent: %s", ex)
|
||||
|
||||
if not handle or not handle.is_valid():
|
||||
log.debug("torrent handle is invalid!")
|
||||
|
@ -534,23 +487,19 @@ class TorrentManager(component.Component):
|
|||
# Write the .torrent file to the state directory
|
||||
if filedump:
|
||||
try:
|
||||
save_file = open(os.path.join(self.state_dir, torrent.torrent_id + ".torrent"), "wb")
|
||||
save_file.write(filedump)
|
||||
save_file.close()
|
||||
except IOError, e:
|
||||
log.warning("Unable to save torrent file: %s", e)
|
||||
with open(os.path.join(self.state_dir, torrent.torrent_id + ".torrent"), "wb") as save_file:
|
||||
save_file.write(filedump)
|
||||
except IOError as ex:
|
||||
log.warning("Unable to save torrent file: %s", ex)
|
||||
|
||||
# If the user has requested a copy of the torrent be saved elsewhere
|
||||
# we need to do that.
|
||||
if self.config["copy_torrent_file"] and filename is not None:
|
||||
try:
|
||||
save_file = open(
|
||||
os.path.join(self.config["torrentfiles_location"], filename),
|
||||
"wb")
|
||||
save_file.write(filedump)
|
||||
save_file.close()
|
||||
except IOError, e:
|
||||
log.warning("Unable to save torrent file: %s", e)
|
||||
with open(os.path.join(self.config["torrentfiles_location"], filename), "wb") as save_file:
|
||||
save_file.write(filedump)
|
||||
except IOError as ex:
|
||||
log.warning("Unable to save torrent file: %s", ex)
|
||||
|
||||
if save_state:
|
||||
# Save the session state
|
||||
|
@ -564,27 +513,22 @@ class TorrentManager(component.Component):
|
|||
|
||||
if log.isEnabledFor(logging.INFO):
|
||||
name_and_owner = torrent.get_status(["name", "owner"])
|
||||
log.info("Torrent %s from user \"%s\" %s" % (
|
||||
name_and_owner["name"],
|
||||
name_and_owner["owner"],
|
||||
from_state and "loaded" or "added")
|
||||
)
|
||||
log.info("Torrent %s from user \"%s\" %s",
|
||||
name_and_owner["name"],
|
||||
name_and_owner["owner"],
|
||||
from_state and "loaded" or "added")
|
||||
return torrent.torrent_id
|
||||
|
||||
def load_torrent(self, torrent_id):
|
||||
"""Load a torrent file from state and return it's torrent info"""
|
||||
filedump = None
|
||||
# Get the torrent data from the torrent file
|
||||
try:
|
||||
log.debug("Attempting to open %s for add.", torrent_id)
|
||||
_file = open(os.path.join(self.state_dir, torrent_id + ".torrent"), "rb")
|
||||
filedump = lt.bdecode(_file.read())
|
||||
_file.close()
|
||||
except (IOError, RuntimeError), e:
|
||||
log.warning("Unable to open %s: %s", torrent_id, e)
|
||||
return False
|
||||
|
||||
return filedump
|
||||
with open(os.path.join(self.state_dir, torrent_id + ".torrent"), "rb") as _file:
|
||||
filedump = lt.bdecode(_file.read())
|
||||
except (IOError, RuntimeError) as ex:
|
||||
log.warning("Unable to open torrent file %s: %s", torrent_id, ex)
|
||||
else:
|
||||
return filedump
|
||||
|
||||
def remove(self, torrent_id, remove_data=False):
|
||||
"""
|
||||
|
@ -610,10 +554,9 @@ class TorrentManager(component.Component):
|
|||
component.get("EventManager").emit(PreTorrentRemovedEvent(torrent_id))
|
||||
|
||||
try:
|
||||
self.session.remove_torrent(self.torrents[torrent_id].handle,
|
||||
1 if remove_data else 0)
|
||||
except (RuntimeError, KeyError), e:
|
||||
log.warning("Error removing torrent: %s", e)
|
||||
self.session.remove_torrent(self.torrents[torrent_id].handle, 1 if remove_data else 0)
|
||||
except (RuntimeError, KeyError) as ex:
|
||||
log.warning("Error removing torrent: %s", ex)
|
||||
return False
|
||||
|
||||
# Remove fastresume data if it is exists
|
||||
|
@ -624,18 +567,13 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Remove the torrent file from the user specified directory
|
||||
filename = self.torrents[torrent_id].filename
|
||||
if self.config["copy_torrent_file"] \
|
||||
and self.config["del_copy_torrent_file"] \
|
||||
and filename:
|
||||
if self.config["copy_torrent_file"] and self.config["del_copy_torrent_file"] and filename:
|
||||
users_torrent_file = os.path.join(self.config["torrentfiles_location"], filename)
|
||||
log.info("Delete user's torrent file: %s", users_torrent_file)
|
||||
try:
|
||||
users_torrent_file = os.path.join(
|
||||
self.config["torrentfiles_location"],
|
||||
filename)
|
||||
log.info("Delete user's torrent file: %s",
|
||||
users_torrent_file)
|
||||
os.remove(users_torrent_file)
|
||||
except Exception, e:
|
||||
log.warning("Unable to remove copy torrent file: %s", e)
|
||||
except OSError as ex:
|
||||
log.warning("Unable to remove copy torrent file: %s", ex)
|
||||
|
||||
# Stop the looping call
|
||||
self.torrents[torrent_id].prev_status_cleanup_loop.stop()
|
||||
|
@ -673,7 +611,7 @@ class TorrentManager(component.Component):
|
|||
try:
|
||||
with open(_filepath, "rb") as _file:
|
||||
state = cPickle.load(_file)
|
||||
except (IOError, EOFError, cPickle.UnpicklingError), ex:
|
||||
except (IOError, EOFError, cPickle.UnpicklingError) as ex:
|
||||
log.warning("Unable to load %s: %s", _filepath, ex)
|
||||
state = None
|
||||
else:
|
||||
|
@ -683,17 +621,6 @@ class TorrentManager(component.Component):
|
|||
if state is None:
|
||||
state = TorrentManagerState()
|
||||
|
||||
# Try to use an old state
|
||||
try:
|
||||
if len(state.torrents) > 0:
|
||||
state_tmp = TorrentState()
|
||||
if dir(state.torrents[0]) != dir(state_tmp):
|
||||
for attr in (set(dir(state_tmp)) - set(dir(state.torrents[0]))):
|
||||
for s in state.torrents:
|
||||
setattr(s, attr, getattr(state_tmp, attr, None))
|
||||
except Exception, e:
|
||||
log.exception("Unable to update state file to a compatible version: %s", e)
|
||||
|
||||
# Reorder the state.torrents list to add torrents in the correct queue
|
||||
# order.
|
||||
state.torrents.sort(key=operator.attrgetter("queue"), reverse=self.config["queue_new_to_top"])
|
||||
|
@ -707,8 +634,8 @@ class TorrentManager(component.Component):
|
|||
try:
|
||||
self.add(state=torrent_state, save_state=False,
|
||||
resume_data=resume_data.get(torrent_state.torrent_id))
|
||||
except AttributeError, e:
|
||||
log.error("Torrent state file is either corrupt or incompatible! %s", e)
|
||||
except AttributeError as ex:
|
||||
log.error("Torrent state file is either corrupt or incompatible! %s", ex)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
break
|
||||
|
@ -729,8 +656,7 @@ class TorrentManager(component.Component):
|
|||
torrent_status = torrent.get_status([
|
||||
"total_uploaded",
|
||||
"last_seen_complete"
|
||||
], update=True
|
||||
)
|
||||
], update=True)
|
||||
|
||||
torrent_state = TorrentState(
|
||||
torrent.torrent_id,
|
||||
|
@ -802,7 +728,7 @@ class TorrentManager(component.Component):
|
|||
|
||||
deferreds = []
|
||||
|
||||
def on_torrent_resume_save(result, torrent_id):
|
||||
def on_torrent_resume_save(dummy_result, torrent_id):
|
||||
self.waiting_on_resume_data.pop(torrent_id, None)
|
||||
|
||||
for torrent_id in torrent_ids:
|
||||
|
@ -816,11 +742,16 @@ class TorrentManager(component.Component):
|
|||
def on_all_resume_data_finished(result):
|
||||
if result:
|
||||
if self.save_resume_data_file():
|
||||
# Return True for the remove_temp_file() callback in stop()
|
||||
return True
|
||||
|
||||
return DeferredList(deferreds).addBoth(on_all_resume_data_finished)
|
||||
|
||||
def load_resume_data_file(self):
|
||||
"""Loads the resume data from file for all the torrents
|
||||
:returns: resume_data
|
||||
:rtype: dict
|
||||
"""
|
||||
filename = "torrents.fastresume"
|
||||
filepath = os.path.join(self.state_dir, filename)
|
||||
filepath_bak = filepath + ".bak"
|
||||
|
@ -831,7 +762,7 @@ class TorrentManager(component.Component):
|
|||
try:
|
||||
with open(_filepath, "rb") as _file:
|
||||
resume_data = lt.bdecode(_file.read())
|
||||
except (IOError, EOFError, RuntimeError), ex:
|
||||
except (IOError, EOFError, RuntimeError) as ex:
|
||||
log.warning("Unable to load %s: %s", _filepath, ex)
|
||||
resume_data = None
|
||||
else:
|
||||
|
@ -911,28 +842,29 @@ class TorrentManager(component.Component):
|
|||
|
||||
def on_set_max_connections_per_torrent(self, key, value):
|
||||
"""Sets the per-torrent connection limit"""
|
||||
log.debug("max_connections_per_torrent set to %s..", value)
|
||||
log.debug("max_connections_per_torrent set to %s...", value)
|
||||
for key in self.torrents.keys():
|
||||
self.torrents[key].set_max_connections(value)
|
||||
|
||||
def on_set_max_upload_slots_per_torrent(self, key, value):
|
||||
"""Sets the per-torrent upload slot limit"""
|
||||
log.debug("max_upload_slots_per_torrent set to %s..", value)
|
||||
log.debug("max_upload_slots_per_torrent set to %s...", value)
|
||||
for key in self.torrents.keys():
|
||||
self.torrents[key].set_max_upload_slots(value)
|
||||
|
||||
def on_set_max_upload_speed_per_torrent(self, key, value):
|
||||
log.debug("max_upload_speed_per_torrent set to %s..", value)
|
||||
log.debug("max_upload_speed_per_torrent set to %s...", value)
|
||||
for key in self.torrents.keys():
|
||||
self.torrents[key].set_max_upload_speed(value)
|
||||
|
||||
def on_set_max_download_speed_per_torrent(self, key, value):
|
||||
log.debug("max_download_speed_per_torrent set to %s..", value)
|
||||
log.debug("max_download_speed_per_torrent set to %s...", value)
|
||||
for key in self.torrents.keys():
|
||||
self.torrents[key].set_max_download_speed(value)
|
||||
|
||||
## Alert handlers ##
|
||||
def on_alert_torrent_finished(self, alert):
|
||||
"""Alert handler for libtorrent torrent_finished_alert"""
|
||||
log.debug("on_alert_torrent_finished")
|
||||
try:
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
|
@ -944,9 +876,10 @@ class TorrentManager(component.Component):
|
|||
# If total_download is 0, do not move, it's likely the torrent wasn't downloaded, but just added.
|
||||
total_download = torrent.get_status(["total_payload_download"])["total_payload_download"]
|
||||
|
||||
log.debug("Torrent settings: is_finished: %s, total_download: %s, move_completed: %s, move_path: %s",
|
||||
torrent.is_finished, total_download, torrent.options["move_completed"],
|
||||
torrent.options["move_completed_path"])
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("Torrent settings: is_finished: %s, total_download: %s, move_completed: %s, move_path: %s",
|
||||
torrent.is_finished, total_download, torrent.options["move_completed"],
|
||||
torrent.options["move_completed_path"])
|
||||
|
||||
# Move completed download to completed folder if needed
|
||||
if not torrent.is_finished and total_download and torrent.options["move_completed"]:
|
||||
|
@ -956,6 +889,7 @@ class TorrentManager(component.Component):
|
|||
torrent.update_state()
|
||||
if not torrent.is_finished and total_download:
|
||||
component.get("EventManager").emit(TorrentFinishedEvent(torrent_id))
|
||||
|
||||
torrent.is_finished = True
|
||||
|
||||
# Torrent is no longer part of the queue
|
||||
|
@ -963,7 +897,8 @@ class TorrentManager(component.Component):
|
|||
self.queued_torrents.remove(torrent_id)
|
||||
except KeyError:
|
||||
# Sometimes libtorrent fires a TorrentFinishedEvent twice
|
||||
log.debug("%s isn't in queued torrents set?", torrent_id)
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("%s isn't in queued torrents set?", torrent_id)
|
||||
|
||||
# Only save resume data if it was actually downloaded something. Helps
|
||||
# on startup with big queues with lots of seeding torrents. Libtorrent
|
||||
|
@ -974,12 +909,13 @@ class TorrentManager(component.Component):
|
|||
self.save_resume_data((torrent_id, ))
|
||||
|
||||
def on_alert_torrent_paused(self, alert):
|
||||
"""Alert handler for libtorrent torrent_paused_alert"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_torrent_paused")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except:
|
||||
torrent = self.torrents[torrent_id]
|
||||
except (RuntimeError, KeyError):
|
||||
return
|
||||
# Set the torrent state
|
||||
old_state = torrent.state
|
||||
|
@ -992,11 +928,12 @@ class TorrentManager(component.Component):
|
|||
self.save_resume_data((torrent_id,))
|
||||
|
||||
def on_alert_torrent_checked(self, alert):
|
||||
"""Alert handler for libtorrent torrent_checked_alert"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_torrent_checked")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
# Check to see if we're forcing a recheck and set it back to paused
|
||||
|
@ -1010,11 +947,12 @@ class TorrentManager(component.Component):
|
|||
torrent.update_state()
|
||||
|
||||
def on_alert_tracker_reply(self, alert):
|
||||
"""Alert handler for libtorrent tracker_reply_alert"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_tracker_reply: %s", decode_string(alert.message()))
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
# Set the tracker status for the torrent
|
||||
|
@ -1022,55 +960,60 @@ class TorrentManager(component.Component):
|
|||
|
||||
# Check to see if we got any peer information from the tracker
|
||||
if alert.handle.status().num_complete == -1 or \
|
||||
alert.handle.status().num_incomplete == -1:
|
||||
alert.handle.status().num_incomplete == -1:
|
||||
# We didn't get peer information, so lets send a scrape request
|
||||
torrent.scrape_tracker()
|
||||
|
||||
def on_alert_tracker_announce(self, alert):
|
||||
"""Alert handler for libtorrent tracker_announce_alert"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_tracker_announce")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
# Set the tracker status for the torrent
|
||||
torrent.set_tracker_status(_("Announce Sent"))
|
||||
|
||||
def on_alert_tracker_warning(self, alert):
|
||||
"""Alert handler for libtorrent tracker_warning_alert"""
|
||||
log.debug("on_alert_tracker_warning")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
tracker_status = '%s: %s' % (_("Warning"), decode_string(alert.message()))
|
||||
# Set the tracker status for the torrent
|
||||
torrent.set_tracker_status(tracker_status)
|
||||
|
||||
def on_alert_tracker_error(self, alert):
|
||||
"""Alert handler for libtorrent tracker_error_alert"""
|
||||
log.debug("on_alert_tracker_error")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
tracker_status = "%s: %s" % (_("Error"), decode_string(alert.msg))
|
||||
torrent.set_tracker_status(tracker_status)
|
||||
|
||||
def on_alert_storage_moved(self, alert):
|
||||
"""Alert handler for libtorrent storage_moved_alert"""
|
||||
log.debug("on_alert_storage_moved")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
torrent.set_save_path(os.path.normpath(alert.handle.save_path()))
|
||||
torrent.set_move_completed(False)
|
||||
|
||||
def on_alert_torrent_resumed(self, alert):
|
||||
"""Alert handler for libtorrent torrent_resumed_alert"""
|
||||
log.debug("on_alert_torrent_resumed")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except:
|
||||
torrent = self.torrents[torrent_id]
|
||||
except (RuntimeError, KeyError):
|
||||
return
|
||||
old_state = torrent.state
|
||||
torrent.update_state()
|
||||
|
@ -1080,12 +1023,15 @@ class TorrentManager(component.Component):
|
|||
component.get("EventManager").emit(TorrentResumedEvent(torrent_id))
|
||||
|
||||
def on_alert_state_changed(self, alert):
|
||||
"""Alert handler for libtorrent state_changed_alert
|
||||
Emits a TorrentStateChangedEvent if state has changed
|
||||
"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_state_changed")
|
||||
try:
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
torrent = self.torrents[torrent_id]
|
||||
except:
|
||||
except (RuntimeError, KeyError):
|
||||
return
|
||||
|
||||
old_state = torrent.state
|
||||
|
@ -1101,9 +1047,13 @@ class TorrentManager(component.Component):
|
|||
component.get("EventManager").emit(TorrentStateChangedEvent(torrent_id, torrent.state))
|
||||
|
||||
def on_alert_save_resume_data(self, alert):
|
||||
"""Alert handler for libtorrent save_resume_data_alert"""
|
||||
if log.isEnabledFor(logging.DEBUG):
|
||||
log.debug("on_alert_save_resume_data")
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
try:
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
if torrent_id in self.torrents:
|
||||
# Libtorrent in add_torrent() expects resume_data to be bencoded
|
||||
|
@ -1113,19 +1063,26 @@ class TorrentManager(component.Component):
|
|||
self.waiting_on_resume_data[torrent_id].callback(None)
|
||||
|
||||
def on_alert_save_resume_data_failed(self, alert):
|
||||
"""Alert handler for libtorrent save_resume_data_failed_alert"""
|
||||
log.debug("on_alert_save_resume_data_failed: %s", decode_string(alert.message()))
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
try:
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except RuntimeError:
|
||||
return
|
||||
|
||||
if torrent_id in self.waiting_on_resume_data:
|
||||
self.waiting_on_resume_data[torrent_id].errback(Exception(decode_string(alert.message())))
|
||||
|
||||
def on_alert_file_renamed(self, alert):
|
||||
"""Alert handler for libtorrent file_renamed_alert
|
||||
Emits a TorrentFileCompletedEvent for renamed files
|
||||
"""
|
||||
log.debug("on_alert_file_renamed")
|
||||
log.debug("index: %s name: %s", alert.index, decode_string(alert.name))
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except:
|
||||
torrent = self.torrents[torrent_id]
|
||||
except (RuntimeError, KeyError):
|
||||
return
|
||||
|
||||
# We need to see if this file index is in a waiting_on_folder dict
|
||||
|
@ -1139,30 +1096,53 @@ class TorrentManager(component.Component):
|
|||
self.save_resume_data((torrent_id,))
|
||||
|
||||
def on_alert_metadata_received(self, alert):
|
||||
"""Alert handler for libtorrent metadata_received_alert"""
|
||||
log.debug("on_alert_metadata_received")
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
torrent.on_metadata_received()
|
||||
|
||||
def on_alert_file_error(self, alert):
|
||||
"""Alert handler for libtorrent file_error_alert"""
|
||||
log.debug("on_alert_file_error: %s", decode_string(alert.message()))
|
||||
try:
|
||||
torrent = self.torrents[str(alert.handle.info_hash())]
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
torrent.update_state()
|
||||
|
||||
def on_alert_file_completed(self, alert):
|
||||
"""Alert handler for libtorrent file_completed_alert
|
||||
Emits a TorrentFileCompletedEvent when an individual file completes downloading
|
||||
"""
|
||||
log.debug("file_completed_alert: %s", decode_string(alert.message()))
|
||||
try:
|
||||
torrent_id = str(alert.handle.info_hash())
|
||||
except:
|
||||
except RuntimeError:
|
||||
return
|
||||
component.get("EventManager").emit(
|
||||
TorrentFileCompletedEvent(torrent_id, alert.index))
|
||||
|
||||
def on_alert_state_update(self, alert):
|
||||
"""Alert handler for libtorrent state_update_alert
|
||||
Result of a session.post_torrent_updates() call and contains the torrent status
|
||||
of all torrents that changed since last time this was posted.
|
||||
"""
|
||||
log.debug("on_status_notification: %s", alert.message())
|
||||
self.last_state_update_alert_ts = time.time()
|
||||
|
||||
for t_status in alert.status:
|
||||
try:
|
||||
torrent_id = str(t_status.info_hash)
|
||||
except RuntimeError:
|
||||
continue
|
||||
if torrent_id in self.torrents:
|
||||
self.torrents[torrent_id].update_status(t_status)
|
||||
|
||||
self.handle_torrents_status_callback(self.torrents_status_requests.pop())
|
||||
|
||||
def separate_keys(self, keys, torrent_ids):
|
||||
"""Separates the input keys into keys for the Torrent class
|
||||
and keys for plugins.
|
||||
|
@ -1176,17 +1156,6 @@ class TorrentManager(component.Component):
|
|||
return torrent_keys, leftover_keys
|
||||
return [], []
|
||||
|
||||
def on_alert_state_update(self, alert):
|
||||
log.debug("on_status_notification: %s", alert.message())
|
||||
self.last_state_update_alert_ts = time.time()
|
||||
|
||||
for s in alert.status:
|
||||
torrent_id = str(s.info_hash)
|
||||
if torrent_id in self.torrents:
|
||||
self.torrents[torrent_id].update_status(s)
|
||||
|
||||
self.handle_torrents_status_callback(self.torrents_status_requests.pop())
|
||||
|
||||
def handle_torrents_status_callback(self, status_request):
|
||||
"""
|
||||
Builds the status dictionary with the values from the Torrent.
|
||||
|
|
Loading…
Reference in New Issue