Merge branch 'nightly' into python3

# Conflicts:
#	plexpy/activity_pinger.py
#	plexpy/activity_processor.py
#	plexpy/helpers.py
#	plexpy/notifiers.py
#	plexpy/version.py
#	plexpy/webserve.py
This commit is contained in:
JonnyWong16 2020-04-27 18:19:48 -07:00
commit d8f223327e
No known key found for this signature in database
GPG key ID: B1F1F9807184697A
47 changed files with 566 additions and 3201 deletions

15
API.md
View file

@ -672,7 +672,7 @@ Returns:
### get_geoip_lookup
Get the geolocation info for an IP address. The GeoLite2 database must be installed.
Get the geolocation info for an IP address.
```
Required parameters:
@ -683,7 +683,7 @@ Optional parameters:
Returns:
json:
{"continent": "North America",
{"code": 'US",
"country": "United States",
"region": "California",
"city": "Mountain View",
@ -693,9 +693,6 @@ Returns:
"longitude": -122.0838,
"accuracy": 1000
}
json:
{"error": "The address 127.0.0.1 is not in the database."
}
```
@ -2574,10 +2571,6 @@ Returns:
```
### install_geoip_db
Downloads and installs the GeoLite2 database
### notify
Send a notification using Tautulli.
@ -2854,10 +2847,6 @@ Returns:
```
### uninstall_geoip_db
Uninstalls the GeoLite2 database
### update
Update Tautulli.

View file

@ -1,5 +1,23 @@
# Changelog
## v2.2.3-beta (2020-04-27)
* Notifications:
* New: Added Plex Android / iOS App notification agent.
* New: Added bandwidth notification parameters.
* New: Added user thumb to notification parameters.
* New: Added initial stream notification parameter and threshold setting to determine if a stream is the first stream of a continuous streaming session.
* New: Added Plex remote access notification parameters.
* Fix: The rating key notification parameter was being overwritten when 3rd party lookup was enabled.
* Fix: Missing artist value for Musicbrainz lookup in certain situations.
* UI:
* Fix: History table was not being refreshed after deleting entries.
* Other:
* Fix: Auto-updater was not scheduled when enabling the setting unless Tautulli was restarted.
* Change: Remove the unnecessary optional Plex logs volume from the Docker image.
* Change: Use Plex.tv for GeoIP lookup instead of requiring the MaxMind GeoLite2 database.
## v2.2.2-beta (2020-04-12)
* Notifications:
@ -14,14 +32,14 @@
* Fix: XBMC platform icon not being redirected to the Kodi platform icon.
* Change: Improved deleting libraries so libraries with the same section ID are not also deleted.
* API:
* Fix: Returning XML for the API failing due to unicode characters.
* Fix: Returning XML from the API failing due to unicode characters.
* Fix: Grouping parameter for various API commands not falling back to default setting.
* New: Added time_queries parameter to get_library_watch_time_stats and get_user_watch_time_stats API command. (Thanks @KaasKop97)
* New: Added an "is_active" return value to the get_user, get_users, get_library, and get_libraries API commands which indicates if the user or library is on the Plex server.
* New: Added delete_history API command.
* Change: Added optional parameter for row_ids for delete_library, delete_user, delete_all_library_history, and delete_all_user_history API commands.
* Mobile App:
* Fix: Temporary device token not being invalidated after cancelling device registration.
* Fix: Temporary device token was not being invalidated after cancelling device registration.
* Other:
* Fix: Update failing on CentOS due to an older git version.
* Fix: Manifest file for creating a web app had incorrect info.

View file

@ -18,6 +18,6 @@ COPY . /app
CMD [ "python", "Tautulli.py", "--datadir", "/config" ]
VOLUME /config /plex_logs
VOLUME /config
EXPOSE 8181
HEALTHCHECK --start-period=90s CMD curl -ILfSs http://localhost:8181/status > /dev/null || curl -ILfkSs https://localhost:8181/status > /dev/null || exit 1

View file

@ -35,8 +35,8 @@ This project is based on code from [Headphones](https://github.com/rembo10/headp
| Status | Branch: `master` | Branch: `beta` | Branch: `nightly` |
| --- | --- | --- | --- |
| Release | [![Release@master](https://img.shields.io/github/v/release/Tautulli/Tautulli?style=flat-square)](https://github.com/Tautulli/Tautulli/releases/latest) <br> [![Release Date@master](https://img.shields.io/github/release-date/Tautulli/Tautulli?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/releases/latest) | [![Release@beta](https://img.shields.io/github/v/release/Tautulli/Tautulli?include_prereleases&style=flat-square)](https://github.com/Tautulli/Tautulli/releases) <br> [![Commits@nightly](https://img.shields.io/github/commits-since/Tautulli/Tautulli/latest/beta?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/beta) | [![Last Commits@nightly](https://img.shields.io/github/last-commit/Tautulli/Tautulli/nightly?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/nightly) <br> [![Commits@nightly](https://img.shields.io/github/commits-since/Tautulli/Tautulli/latest/nightly?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/nightly) |
| Docker | [![Docker@master](https://img.shields.io/badge/tautulli-tautulli:latest-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@master](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/master?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Amaster) | [![Docker@beta](https://img.shields.io/badge/tautulli-tautulli:beta-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@master](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/beta?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Abeta) | [![Docker@nightly](https://img.shields.io/badge/tautulli-tautulli:nightly-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@master](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/nightly?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Anightly) |
| Release | [![Release@master](https://img.shields.io/github/v/release/Tautulli/Tautulli?style=flat-square)](https://github.com/Tautulli/Tautulli/releases/latest) <br> [![Release Date@master](https://img.shields.io/github/release-date/Tautulli/Tautulli?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/releases/latest) | [![Release@beta](https://img.shields.io/github/v/release/Tautulli/Tautulli?include_prereleases&style=flat-square)](https://github.com/Tautulli/Tautulli/releases) <br> [![Commits@beta](https://img.shields.io/github/commits-since/Tautulli/Tautulli/latest/beta?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/beta) | [![Last Commits@nightly](https://img.shields.io/github/last-commit/Tautulli/Tautulli/nightly?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/nightly) <br> [![Commits@nightly](https://img.shields.io/github/commits-since/Tautulli/Tautulli/latest/nightly?style=flat-square&color=blue)](https://github.com/Tautulli/Tautulli/commits/nightly) |
| Docker | [![Docker@master](https://img.shields.io/badge/tautulli-tautulli:latest-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@master](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/master?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Amaster) | [![Docker@beta](https://img.shields.io/badge/tautulli-tautulli:beta-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@beta](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/beta?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Abeta) | [![Docker@nightly](https://img.shields.io/badge/tautulli-tautulli:nightly-blue?style=flat-square)](https://hub.docker.com/r/tautulli/tautulli) <br> [![Docker Build@nightly](https://img.shields.io/github/workflow/status/Tautulli/Tautulli/Publish%20Docker/nightly?style=flat-square)](https://github.com/Tautulli/Tautulli/actions?query=workflow%3A"Publish+Docker"+branch%3Anightly) |
[![Wiki](https://img.shields.io/badge/github-wiki-black?style=flat-square)](https://github.com/Tautulli/Tautulli-Wiki/wiki)
[![Discord](https://img.shields.io/discord/183396325142822912?label=discord&style=flat-square&color=7289DA)](https://tautulli.com/discord)

View file

@ -193,9 +193,9 @@
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
history_table.draw();
});
}

View file

@ -729,9 +729,9 @@ DOCUMENTATION :: END
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
history_table.draw();
});
}

View file

@ -24,7 +24,6 @@
<div id="ip_error" class="col-sm-12 text-muted"></div>
<div class="col-sm-6">
<ul class="list-unstyled">
<li>Continent: <strong><span id="continent"></span></strong></li>
<li>Country: <strong><span id="country"></span></strong></li>
<li>Region: <strong><span id="region"></span></strong></li>
<li>City: <strong><span id="city"></span></strong></li>
@ -36,7 +35,6 @@
<li>Timezone: <strong><span id="timezone"></span></strong></li>
<li>Latitude: <strong><span id="latitude"></span></strong></li>
<li>Longitude: <strong><span id="longitude"></span></strong></li>
<li>Accuracy Radius: <strong><span id="accuracy"></span></strong></li>
</ul>
</div>
<div class="col-sm-12">
@ -61,8 +59,6 @@
</div>
</div>
<div class="modal-footer">
<% from plexpy.helpers import anon_url %>
<span class="text-muted">GeoLite2 data created by <a href="${anon_url('http://www.maxmind.com')}" target="_blank">MaxMind</a>.</span>
</div>
</div>
</div>
@ -82,11 +78,11 @@
error: function () {
$('#ip_error').html('<i class="fa fa-exclamation-circle"></i> Internal request failed.').show();
},
success: function (data) {
if ('error' in data) {
$('#ip_error').html('<i class="fa fa-exclamation-circle"></i> ' + data.error).show();
success: function (result) {
if (result.results === 'error') {
$('#ip_error').html('<i class="fa fa-exclamation-circle"></i> ' + result.message).show();
} else {
$('#continent').html(data.continent);
var data = result.data;
$('#country').html(data.country);
$('#region').html(data.region);
$('#city').html(data.city);
@ -94,7 +90,6 @@
$('#timezone').html(data.timezone);
$('#latitude').html(data.latitude);
$('#longitude').html(data.longitude);
$('#accuracy').html(data.accuracy + ' km');
}
}
});

View file

@ -493,9 +493,9 @@ DOCUMENTATION :: END
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
history_table.draw();
});
}

View file

@ -1,9 +1,9 @@
% if notifier:
<%!
<%
import json
from plexpy import notifiers, users
from plexpy.helpers import checked
available_notification_actions = notifiers.available_notification_actions()
available_notification_actions = notifiers.available_notification_actions(agent_id=notifier['agent_id'])
user_emails = [{'user': u['friendly_name'] or u['username'], 'email': u['email']} for u in users.Users().get_users() if u['email']]
sorted(user_emails, key=lambda u: u['user'])
@ -25,7 +25,7 @@
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Arguments</a></li>
% elif notifier['agent_name'] == 'webhook':
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Data</a></li>
% else:
% elif notifier['agent_name'] != 'plexmobileapp':
<li role="presentation"><a href="#tabs-notify_text" aria-controls="tabs-notify_text" role="tab" data-toggle="tab">Text</a></li>
% endif
<li role="presentation"><a href="#tabs-test_notifications" aria-controls="tabs-test_notifications" role="tab" data-toggle="tab">Test Notifications</a></li>
@ -684,6 +684,15 @@
pushoverPriority();
});
% elif notifier['agent_name'] == 'plexmobileapp':
var $plexmobileapp_user_ids = $('#plexmobileapp_user_ids').selectize({
plugins: ['remove_button'],
maxItems: null,
create: true
});
var plexmobileapp_user_ids = $plexmobileapp_user_ids[0].selectize;
plexmobileapp_user_ids.setValue(${json.dumps(next((c['value'] for c in notifier['config_options'] if c['name'] == 'plexmobileapp_user_ids'), [])) | n});
% endif
function validateLogic() {

View file

@ -941,7 +941,7 @@
</div>
<div id="buffer_wait_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">The value (in seconds) Tautulli should wait before triggering the next buffer warning. 0 to always trigger.</p>
<p class="help-block">The value (in seconds) Tautulli should wait before triggering the next buffer warning. Set to 0 to always trigger.</p>
</div>
<div class="checkbox advanced-setting">
<label>
@ -965,6 +965,20 @@
</div>
<p class="help-block">The number of concurrent streams by a single user for Tautulli to trigger a notification. Minimum 2.</p>
</div>
<div class="form-group advanced-setting">
<label for="notify_concurrent_threshold">Continued Session Threshold</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="notify_continued_session_threshold" name="notify_continued_session_threshold" value="${config['notify_continued_session_threshold']}" data-parsley-min="0" data-parsley-trigger="change" data-parsley-errors-container="#notify_continued_session_threshold_error" required>
</div>
<div id="notify_continued_session_threshold_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
The number of seconds between stopping and starting a new stream to be considered as a continued session. Set to 0 to consider all streams as new sessions.
<br>
Note: The threshold is only used by the "Initial Stream" notification parameter to determine if a stream is the first stream of a continuous streaming session.
</p>
</div>
<div class="padded-header">
<h3>Recently Added Notifications</h3>
@ -1254,7 +1268,7 @@
<p class="help-block">Enable to lookup links to MusicBrainz for music when available.</p>
</div>
<div class="form-group">
<label for="maxmind_license_key">Delete Lookup Info</label>
<label for="delete_lookup_info">Delete Lookup Info</label>
<p class="help-block">Delete all cached metadata lookup info in Tautulli.</p>
<div class="row">
<div class="col-md-9">
@ -1267,54 +1281,6 @@
</div>
</div>
<div class="padded-header">
<h3>Geolocation Database</h3>
</div>
<p class="help-block">The GeoLite2 database is used to geolocate IP addresses.</p>
<p class="help-block">
Please see the <a target='_blank' href='${anon_url('https://github.com/%s/%s-Wiki/wiki/3rd-Party-APIs-Guide' % (plexpy.CONFIG.GIT_USER, plexpy.CONFIG.GIT_REPO))}'>3rd Party APIs Guide</a> for instructions on setting up MaxMind.<br>
</p>
<div class="form-group">
<label for="maxmind_license_key">MaxMind License Key</label>
<div class="row">
<div class="col-md-6">
<input type="text" class="form-control" id="maxmind_license_key" name="maxmind_license_key" value="${config['maxmind_license_key']}" data-parsley-trigger="change">
</div>
</div>
<p class="help-block">
Enter your MaxMind License Key to install the GeoLite2 database.
</p>
</div>
<div class="form-group">
<label for="geoip_db">GeoLite2 Database File</label> ${docker_msg | n}
<div class="row">
<div class="col-md-9">
<div class="input-group">
<input type="text" class="form-control" id="geoip_db" name="geoip_db" value="${config['geoip_db']}" ${docker_setting} data-parsley-trigger="change" data-parsley-pattern=".+\.mmdb$" data-parsley-errors-container="#geoip_db_error" data-parsley-error-message="Must end with '.mmdb'">
<span class="input-group-btn">
<button class="btn btn-form" type="button" id="install_geoip_db">${'Update' if config["geoip_db_installed"] else 'Install'}</button>
<button class="btn btn-form" type="button" id="uninstall_geoip_db" ${'disabled' if not config['geoip_db_installed'] else ''}>Uninstall</button>
</span>
</div>
</div>
<div id="geoip_db_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">
Leave blank to install in the default location. GeoLite2 database last updated <strong><span id="geoip_db_updated">never</span></strong>.
</p>
</div>
<div class="form-group advanced-setting">
<label for="geoip_db_update_days">GeoLite2 Database Update Interval</label>
<div class="row">
<div class="col-md-2">
<input type="text" class="form-control" data-parsley-type="integer" id="geoip_db_update_days" name="geoip_db_update_days" value="${config['geoip_db_update_days']}" size="5" data-parsley-range="[7, 30]" data-parsley-trigger="change" data-parsley-errors-container="#geoip_db_update_days_error" required>
</div>
<div id="geoip_db_update_days_error" class="alert alert-danger settings-alert" role="alert"></div>
</div>
<p class="help-block">The interval (in days) Tautulli will automatically update the GeoLite2 database. Minimum 7, maximum 30, default 30.</p>
</div>
<p><input type="button" class="btn btn-bright save-button" value="Save" data-success="Changes saved successfully"></p>
</div>
@ -2928,56 +2894,6 @@ $(document).ready(function() {
$('#resources-xml').on('tripleclick', function () {
openPlexXML('/api/resources', true, {includeHttps: 1});
});
if ("${kwargs.get('install_geoip')}" === 'true') {
gotoSetting('3rd_party_apis', 'geoip_db')
}
if ("${config['geoip_db_installed']}" > "0") {
$("#geoip_db_updated").text(moment("${config['geoip_db_installed']}", "X").fromNow());
}
$("#install_geoip_db").click(function () {
var maxmind_license_key = $("#maxmind_license_key");
maxmind_license_key.val($.trim(maxmind_license_key.val()));
if (maxmind_license_key.val() === "") {
maxmind_license_key.focus();
showMsg('<i class="fa fa-exclamation-circle"></i> Maxmind License Key is required.', false, true, 5000, true);
return false;
} else if (!(saveSettings())) {
return false;
}
var msg = 'Are you sure you want to install the GeoLite2 database?<br /><br />' +
'The database is used to lookup IP address geolocation info.<br />' +
'The database will be downloaded from <a href="${anon_url("https://dev.maxmind.com/geoip/geoip2/geolite2/")}" target="_blank">MaxMind</a>, <br />' +
'and requires <strong>100MB</strong> of free space to install.<br />';
var url = 'install_geoip_db';
if ($(this).text() === 'Update') {
url += '?update=true';
}
confirmAjaxCall(url, msg, null, 'Installing GeoLite2 database.', function (result) {
if (result.result === "success") {
$('#install_geoip_db').text('Update');
$('#uninstall_geoip_db').prop('disabled', false);
$('#geoip_db_updated').text(moment(result.updated, "X").fromNow());
}
getSchedulerTable();
});
});
$("#uninstall_geoip_db").click(function () {
var msg = 'Are you sure you want to uninstall the GeoLite2 database?<br /><br />' +
'You will not be able to lookup IP address geolocation info.';
var url = 'uninstall_geoip_db';
confirmAjaxCall(url, msg, null, 'Uninstalling GeoLite2 database.', function (result) {
if (result.result === "success") {
$('#install_geoip_db').text('Install');
$('#uninstall_geoip_db').prop('disabled', true);
$('#geoip_db_updated').text('never');
}
getSchedulerTable();
});
});
});
</script>
</%def>

View file

@ -582,9 +582,9 @@ DOCUMENTATION :: END
success: function (data) {
var msg = "History deleted";
showMsg(msg, false, true, 2000);
history_table.draw();
}
});
history_table.draw();
});
}

View file

@ -1,7 +0,0 @@
# pylint:disable=C0111
__title__ = 'geoip2'
__version__ = '2.4.0'
__author__ = 'Gregory Oschwald'
__license__ = 'Apache License, Version 2.0'
__copyright__ = 'Copyright (c) 2013-2016 Maxmind, Inc.'

View file

@ -1,17 +0,0 @@
"""Intended for internal use only."""
import sys
import ipaddress
# pylint: skip-file
if sys.version_info[0] == 2:
def compat_ip_address(address):
"""Intended for internal use only."""
if isinstance(address, bytes):
address = address.decode()
return ipaddress.ip_address(address)
else:
def compat_ip_address(address):
"""Intended for internal use only."""
return ipaddress.ip_address(address)

View file

@ -1,199 +0,0 @@
"""
======================
GeoIP2 Database Reader
======================
"""
import inspect
import maxminddb
# pylint: disable=unused-import
from maxminddb import (MODE_AUTO, MODE_MMAP, MODE_MMAP_EXT, MODE_FILE,
MODE_MEMORY)
import geoip2
import geoip2.models
import geoip2.errors
class Reader(object):
"""GeoIP2 database Reader object.
Instances of this class provide a reader for the GeoIP2 database format.
IP addresses can be looked up using the ``country`` and ``city`` methods.
The basic API for this class is the same for every database. First, you
create a reader object, specifying a file name. You then call the method
corresponding to the specific database, passing it the IP address you want
to look up.
If the request succeeds, the method call will return a model class for the
method you called. This model in turn contains multiple record classes,
each of which represents part of the data returned by the database. If the
database does not contain the requested information, the attributes on the
record class will have a ``None`` value.
If the address is not in the database, an
``geoip2.errors.AddressNotFoundError`` exception will be thrown. If the
database is corrupt or invalid, a ``maxminddb.InvalidDatabaseError`` will
be thrown.
"""
def __init__(self, filename, locales=None, mode=MODE_AUTO):
"""Create GeoIP2 Reader.
:param filename: The path to the GeoIP2 database.
:param locales: This is list of locale codes. This argument will be
passed on to record classes to use when their name properties are
called. The default value is ['en'].
The order of the locales is significant. When a record class has
multiple names (country, city, etc.), its name property will return
the name in the first locale that has one.
Note that the only locale which is always present in the GeoIP2
data is "en". If you do not include this locale, the name property
may end up returning None even when the record has an English name.
Currently, the valid locale codes are:
* de -- German
* en -- English names may still include accented characters if that
is the accepted spelling in English. In other words, English does
not mean ASCII.
* es -- Spanish
* fr -- French
* ja -- Japanese
* pt-BR -- Brazilian Portuguese
* ru -- Russian
* zh-CN -- Simplified Chinese.
:param mode: The mode to open the database with. Valid mode are:
* MODE_MMAP_EXT - use the C extension with memory map.
* MODE_MMAP - read from memory map. Pure Python.
* MODE_FILE - read database as standard file. Pure Python.
* MODE_MEMORY - load database into memory. Pure Python.
* MODE_AUTO - try MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that order.
Default.
"""
if locales is None:
locales = ['en']
self._db_reader = maxminddb.open_database(filename, mode)
self._locales = locales
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, traceback):
self.close()
def country(self, ip_address):
"""Get the Country object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.Country` object
"""
return self._model_for(geoip2.models.Country, 'Country', ip_address)
def city(self, ip_address):
"""Get the City object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.City` object
"""
return self._model_for(geoip2.models.City, 'City', ip_address)
def anonymous_ip(self, ip_address):
"""Get the AnonymousIP object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.AnonymousIP` object
"""
return self._flat_model_for(geoip2.models.AnonymousIP,
'GeoIP2-Anonymous-IP', ip_address)
def connection_type(self, ip_address):
"""Get the ConnectionType object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.ConnectionType` object
"""
return self._flat_model_for(geoip2.models.ConnectionType,
'GeoIP2-Connection-Type', ip_address)
def domain(self, ip_address):
"""Get the Domain object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.Domain` object
"""
return self._flat_model_for(geoip2.models.Domain, 'GeoIP2-Domain',
ip_address)
def enterprise(self, ip_address):
"""Get the Enterprise object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.Enterprise` object
"""
return self._model_for(geoip2.models.Enterprise, 'Enterprise',
ip_address)
def isp(self, ip_address):
"""Get the ISP object for the IP address.
:param ip_address: IPv4 or IPv6 address as a string.
:returns: :py:class:`geoip2.models.ISP` object
"""
return self._flat_model_for(geoip2.models.ISP, 'GeoIP2-ISP',
ip_address)
def _get(self, database_type, ip_address):
if database_type not in self.metadata().database_type:
caller = inspect.stack()[2][3]
raise TypeError("The %s method cannot be used with the "
"%s database" %
(caller, self.metadata().database_type))
record = self._db_reader.get(ip_address)
if record is None:
raise geoip2.errors.AddressNotFoundError(
"The address %s is not in the database." % ip_address)
return record
def _model_for(self, model_class, types, ip_address):
record = self._get(types, ip_address)
record.setdefault('traits', {})['ip_address'] = ip_address
return model_class(record, locales=self._locales)
def _flat_model_for(self, model_class, types, ip_address):
record = self._get(types, ip_address)
record['ip_address'] = ip_address
return model_class(record)
def metadata(self):
"""The metadata for the open database.
:returns: :py:class:`maxminddb.reader.Metadata` object
"""
return self._db_reader.metadata()
def close(self):
"""Closes the GeoIP2 database."""
self._db_reader.close()

View file

@ -1,51 +0,0 @@
"""
Errors
======
"""
class GeoIP2Error(RuntimeError):
"""There was a generic error in GeoIP2.
This class represents a generic error. It extends :py:exc:`RuntimeError`
and does not add any additional attributes.
"""
class AddressNotFoundError(GeoIP2Error):
"""The address you were looking up was not found."""
class AuthenticationError(GeoIP2Error):
"""There was a problem authenticating the request."""
class HTTPError(GeoIP2Error):
"""There was an error when making your HTTP request.
This class represents an HTTP transport error. It extends
:py:exc:`GeoIP2Error` and adds attributes of its own.
:ivar http_status: The HTTP status code returned
:ivar uri: The URI queried
"""
def __init__(self, message, http_status=None, uri=None):
super(HTTPError, self).__init__(message)
self.http_status = http_status
self.uri = uri
class InvalidRequestError(GeoIP2Error):
"""The request was invalid."""
class OutOfQueriesError(GeoIP2Error):
"""Your account is out of funds for the service queried."""
class PermissionRequiredError(GeoIP2Error):
"""Your account does not have permission to access this service."""

View file

@ -1,16 +0,0 @@
"""This package contains utility mixins"""
# pylint: disable=too-few-public-methods
from abc import ABCMeta
class SimpleEquality(object):
"""Naive __dict__ equality mixin"""
__metaclass__ = ABCMeta
def __eq__(self, other):
return (isinstance(other, self.__class__) and
self.__dict__ == other.__dict__)
def __ne__(self, other):
return not self.__eq__(other)

View file

@ -1,472 +0,0 @@
"""
Models
======
These classes provide models for the data returned by the GeoIP2
web service and databases.
The only difference between the City and Insights model classes is which
fields in each record may be populated. See
http://dev.maxmind.com/geoip/geoip2/web-services for more details.
"""
# pylint: disable=too-many-instance-attributes,too-few-public-methods
from abc import ABCMeta
import geoip2.records
from geoip2.mixins import SimpleEquality
class Country(SimpleEquality):
"""Model for the GeoIP2 Precision: Country and the GeoIP2 Country database.
This class provides the following attributes:
.. attribute:: continent
Continent object for the requested IP address.
:type: :py:class:`geoip2.records.Continent`
.. attribute:: country
Country object for the requested IP address. This record represents the
country where MaxMind believes the IP is located.
:type: :py:class:`geoip2.records.Country`
.. attribute:: maxmind
Information related to your MaxMind account.
:type: :py:class:`geoip2.records.MaxMind`
.. attribute:: registered_country
The registered country object for the requested IP address. This record
represents the country where the ISP has registered a given IP block in
and may differ from the user's country.
:type: :py:class:`geoip2.records.Country`
.. attribute:: represented_country
Object for the country represented by the users of the IP address
when that country is different than the country in ``country``. For
instance, the country represented by an overseas military base.
:type: :py:class:`geoip2.records.RepresentedCountry`
.. attribute:: traits
Object with the traits of the requested IP address.
:type: :py:class:`geoip2.records.Traits`
"""
def __init__(self, raw_response, locales=None):
if locales is None:
locales = ['en']
self._locales = locales
self.continent = \
geoip2.records.Continent(locales,
**raw_response.get('continent', {}))
self.country = \
geoip2.records.Country(locales,
**raw_response.get('country', {}))
self.registered_country = \
geoip2.records.Country(locales,
**raw_response.get('registered_country',
{}))
self.represented_country \
= geoip2.records.RepresentedCountry(locales,
**raw_response.get(
'represented_country', {}))
self.maxmind = \
geoip2.records.MaxMind(**raw_response.get('maxmind', {}))
self.traits = geoip2.records.Traits(**raw_response.get('traits', {}))
self.raw = raw_response
def __repr__(self):
return '{module}.{class_name}({data}, {locales})'.format(
module=self.__module__,
class_name=self.__class__.__name__,
data=self.raw,
locales=self._locales)
class City(Country):
"""Model for the GeoIP2 Precision: City and the GeoIP2 City database.
.. attribute:: city
City object for the requested IP address.
:type: :py:class:`geoip2.records.City`
.. attribute:: continent
Continent object for the requested IP address.
:type: :py:class:`geoip2.records.Continent`
.. attribute:: country
Country object for the requested IP address. This record represents the
country where MaxMind believes the IP is located.
:type: :py:class:`geoip2.records.Country`
.. attribute:: location
Location object for the requested IP address.
.. attribute:: maxmind
Information related to your MaxMind account.
:type: :py:class:`geoip2.records.MaxMind`
.. attribute:: registered_country
The registered country object for the requested IP address. This record
represents the country where the ISP has registered a given IP block in
and may differ from the user's country.
:type: :py:class:`geoip2.records.Country`
.. attribute:: represented_country
Object for the country represented by the users of the IP address
when that country is different than the country in ``country``. For
instance, the country represented by an overseas military base.
:type: :py:class:`geoip2.records.RepresentedCountry`
.. attribute:: subdivisions
Object (tuple) representing the subdivisions of the country to which
the location of the requested IP address belongs.
:type: :py:class:`geoip2.records.Subdivisions`
.. attribute:: traits
Object with the traits of the requested IP address.
:type: :py:class:`geoip2.records.Traits`
"""
def __init__(self, raw_response, locales=None):
super(City, self).__init__(raw_response, locales)
self.city = \
geoip2.records.City(locales, **raw_response.get('city', {}))
self.location = \
geoip2.records.Location(**raw_response.get('location', {}))
self.postal = \
geoip2.records.Postal(**raw_response.get('postal', {}))
self.subdivisions = \
geoip2.records.Subdivisions(locales,
*raw_response.get('subdivisions', []))
class Insights(City):
"""Model for the GeoIP2 Precision: Insights web service endpoint.
.. attribute:: city
City object for the requested IP address.
:type: :py:class:`geoip2.records.City`
.. attribute:: continent
Continent object for the requested IP address.
:type: :py:class:`geoip2.records.Continent`
.. attribute:: country
Country object for the requested IP address. This record represents the
country where MaxMind believes the IP is located.
:type: :py:class:`geoip2.records.Country`
.. attribute:: location
Location object for the requested IP address.
.. attribute:: maxmind
Information related to your MaxMind account.
:type: :py:class:`geoip2.records.MaxMind`
.. attribute:: registered_country
The registered country object for the requested IP address. This record
represents the country where the ISP has registered a given IP block in
and may differ from the user's country.
:type: :py:class:`geoip2.records.Country`
.. attribute:: represented_country
Object for the country represented by the users of the IP address
when that country is different than the country in ``country``. For
instance, the country represented by an overseas military base.
:type: :py:class:`geoip2.records.RepresentedCountry`
.. attribute:: subdivisions
Object (tuple) representing the subdivisions of the country to which
the location of the requested IP address belongs.
:type: :py:class:`geoip2.records.Subdivisions`
.. attribute:: traits
Object with the traits of the requested IP address.
:type: :py:class:`geoip2.records.Traits`
"""
class Enterprise(City):
"""Model for the GeoIP2 Enterprise database.
.. attribute:: city
City object for the requested IP address.
:type: :py:class:`geoip2.records.City`
.. attribute:: continent
Continent object for the requested IP address.
:type: :py:class:`geoip2.records.Continent`
.. attribute:: country
Country object for the requested IP address. This record represents the
country where MaxMind believes the IP is located.
:type: :py:class:`geoip2.records.Country`
.. attribute:: location
Location object for the requested IP address.
.. attribute:: maxmind
Information related to your MaxMind account.
:type: :py:class:`geoip2.records.MaxMind`
.. attribute:: registered_country
The registered country object for the requested IP address. This record
represents the country where the ISP has registered a given IP block in
and may differ from the user's country.
:type: :py:class:`geoip2.records.Country`
.. attribute:: represented_country
Object for the country represented by the users of the IP address
when that country is different than the country in ``country``. For
instance, the country represented by an overseas military base.
:type: :py:class:`geoip2.records.RepresentedCountry`
.. attribute:: subdivisions
Object (tuple) representing the subdivisions of the country to which
the location of the requested IP address belongs.
:type: :py:class:`geoip2.records.Subdivisions`
.. attribute:: traits
Object with the traits of the requested IP address.
:type: :py:class:`geoip2.records.Traits`
"""
class SimpleModel(SimpleEquality):
"""Provides basic methods for non-location models"""
__metaclass__ = ABCMeta
def __repr__(self):
# pylint: disable=no-member
return '{module}.{class_name}({data})'.format(
module=self.__module__,
class_name=self.__class__.__name__,
data=str(self.raw))
class AnonymousIP(SimpleModel):
"""Model class for the GeoIP2 Anonymous IP.
This class provides the following attribute:
.. attribute:: is_anonymous
This is true if the IP address belongs to any sort of anonymous network.
:type: bool
.. attribute:: is_anonymous_vpn
This is true if the IP address belongs to an anonymous VPN system.
:type: bool
.. attribute:: is_hosting_provider
This is true if the IP address belongs to a hosting provider.
:type: bool
.. attribute:: is_public_proxy
This is true if the IP address belongs to a public proxy.
:type: bool
.. attribute:: is_tor_exit_node
This is true if the IP address is a Tor exit node.
:type: bool
.. attribute:: ip_address
The IP address used in the lookup.
:type: unicode
"""
def __init__(self, raw):
self.is_anonymous = raw.get('is_anonymous', False)
self.is_anonymous_vpn = raw.get('is_anonymous_vpn', False)
self.is_hosting_provider = raw.get('is_hosting_provider', False)
self.is_public_proxy = raw.get('is_public_proxy', False)
self.is_tor_exit_node = raw.get('is_tor_exit_node', False)
self.ip_address = raw.get('ip_address')
self.raw = raw
class ConnectionType(SimpleModel):
"""Model class for the GeoIP2 Connection-Type.
This class provides the following attribute:
.. attribute:: connection_type
The connection type may take the following values:
- Dialup
- Cable/DSL
- Corporate
- Cellular
Additional values may be added in the future.
:type: unicode
.. attribute:: ip_address
The IP address used in the lookup.
:type: unicode
"""
def __init__(self, raw):
self.connection_type = raw.get('connection_type')
self.ip_address = raw.get('ip_address')
self.raw = raw
class Domain(SimpleModel):
"""Model class for the GeoIP2 Domain.
This class provides the following attribute:
.. attribute:: domain
The domain associated with the IP address.
:type: unicode
.. attribute:: ip_address
The IP address used in the lookup.
:type: unicode
"""
def __init__(self, raw):
self.domain = raw.get('domain')
self.ip_address = raw.get('ip_address')
self.raw = raw
class ISP(SimpleModel):
"""Model class for the GeoIP2 ISP.
This class provides the following attribute:
.. attribute:: autonomous_system_number
The autonomous system number associated with the IP address.
:type: int
.. attribute:: autonomous_system_organization
The organization associated with the registered autonomous system number
for the IP address.
:type: unicode
.. attribute:: isp
The name of the ISP associated with the IP address.
:type: unicode
.. attribute:: organization
The name of the organization associated with the IP address.
:type: unicode
.. attribute:: ip_address
The IP address used in the lookup.
:type: unicode
"""
# pylint:disable=too-many-arguments
def __init__(self, raw):
self.autonomous_system_number = raw.get('autonomous_system_number')
self.autonomous_system_organization = raw.get(
'autonomous_system_organization')
self.isp = raw.get('isp')
self.organization = raw.get('organization')
self.ip_address = raw.get('ip_address')
self.raw = raw

View file

@ -1,605 +0,0 @@
"""
Records
=======
"""
# pylint:disable=R0903
from abc import ABCMeta
from geoip2.mixins import SimpleEquality
class Record(SimpleEquality):
"""All records are subclasses of the abstract class ``Record``."""
__metaclass__ = ABCMeta
_valid_attributes = set()
def __init__(self, **kwargs):
valid_args = dict((k, kwargs.get(k)) for k in self._valid_attributes)
self.__dict__.update(valid_args)
def __setattr__(self, name, value):
raise AttributeError("can't set attribute")
def __repr__(self):
args = ', '.join('%s=%r' % x for x in self.__dict__.items())
return '{module}.{class_name}({data})'.format(
module=self.__module__,
class_name=self.__class__.__name__,
data=args)
class PlaceRecord(Record):
"""All records with :py:attr:`names` subclass :py:class:`PlaceRecord`."""
__metaclass__ = ABCMeta
def __init__(self, locales=None, **kwargs):
if locales is None:
locales = ['en']
if kwargs.get('names') is None:
kwargs['names'] = {}
object.__setattr__(self, '_locales', locales)
super(PlaceRecord, self).__init__(**kwargs)
@property
def name(self):
"""Dict with locale codes as keys and localized name as value."""
# pylint:disable=E1101
return next(
(self.names.get(x) for x in self._locales
if x in self.names), None)
class City(PlaceRecord):
"""Contains data for the city record associated with an IP address.
This class contains the city-level data associated with an IP address.
This record is returned by ``city``, ``enterprise``, and ``insights``.
Attributes:
.. attribute:: confidence
A value from 0-100 indicating MaxMind's
confidence that the city is correct. This attribute is only available
from the Insights end point and the GeoIP2 Enterprise database.
:type: int
.. attribute:: geoname_id
The GeoName ID for the city.
:type: int
.. attribute:: name
The name of the city based on the locales list passed to the
constructor.
:type: unicode
.. attribute:: names
A dictionary where the keys are locale codes
and the values are names.
:type: dict
"""
_valid_attributes = set(['confidence', 'geoname_id', 'names'])
class Continent(PlaceRecord):
"""Contains data for the continent record associated with an IP address.
This class contains the continent-level data associated with an IP
address.
Attributes:
.. attribute:: code
A two character continent code like "NA" (North America)
or "OC" (Oceania).
:type: unicode
.. attribute:: geoname_id
The GeoName ID for the continent.
:type: int
.. attribute:: name
Returns the name of the continent based on the locales list passed to
the constructor.
:type: unicode
.. attribute:: names
A dictionary where the keys are locale codes
and the values are names.
:type: dict
"""
_valid_attributes = set(['code', 'geoname_id', 'names'])
class Country(PlaceRecord):
"""Contains data for the country record associated with an IP address.
This class contains the country-level data associated with an IP address.
Attributes:
.. attribute:: confidence
A value from 0-100 indicating MaxMind's confidence that
the country is correct. This attribute is only available from the
Insights end point and the GeoIP2 Enterprise database.
:type: int
.. attribute:: geoname_id
The GeoName ID for the country.
:type: int
.. attribute:: iso_code
The two-character `ISO 3166-1
<http://en.wikipedia.org/wiki/ISO_3166-1>`_ alpha code for the
country.
:type: unicode
.. attribute:: name
The name of the country based on the locales list passed to the
constructor.
:type: unicode
.. attribute:: names
A dictionary where the keys are locale codes and the values
are names.
:type: dict
"""
_valid_attributes = set(['confidence', 'geoname_id', 'iso_code', 'names'])
class RepresentedCountry(Country):
"""Contains data for the represented country associated with an IP address.
This class contains the country-level data associated with an IP address
for the IP's represented country. The represented country is the country
represented by something like a military base.
Attributes:
.. attribute:: confidence
A value from 0-100 indicating MaxMind's confidence that
the country is correct. This attribute is only available from the
Insights end point and the GeoIP2 Enterprise database.
:type: int
.. attribute:: geoname_id
The GeoName ID for the country.
:type: int
.. attribute:: iso_code
The two-character `ISO 3166-1
<http://en.wikipedia.org/wiki/ISO_3166-1>`_ alpha code for the country.
:type: unicode
.. attribute:: name
The name of the country based on the locales list passed to the
constructor.
:type: unicode
.. attribute:: names
A dictionary where the keys are locale codes and the values
are names.
:type: dict
.. attribute:: type
A string indicating the type of entity that is representing the
country. Currently we only return ``military`` but this could expand to
include other types in the future.
:type: unicode
"""
_valid_attributes = set(['confidence', 'geoname_id', 'iso_code', 'names',
'type'])
class Location(Record):
"""Contains data for the location record associated with an IP address.
This class contains the location data associated with an IP address.
This record is returned by ``city``, ``enterprise``, and ``insights``.
Attributes:
.. attribute:: average_income
The average income in US dollars associated with the requested IP
address. This attribute is only available from the Insights end point.
:type: int
.. attribute:: accuracy_radius
The radius in kilometers around the specified location where the IP
address is likely to be.
:type: int
.. attribute:: latitude
The approximate latitude of the location associated with the IP
address. This value is not precise and should not be used to identify a
particular address or household.
:type: float
.. attribute:: longitude
The approximate longitude of the location associated with the IP
address. This value is not precise and should not be used to identify a
particular address or household.
:type: float
.. attribute:: metro_code
The metro code of the location if the
location is in the US. MaxMind returns the same metro codes as the
`Google AdWords API
<https://developers.google.com/adwords/api/docs/appendix/cities-DMAregions>`_.
:type: int
.. attribute:: population_density
The estimated population per square kilometer associated with the IP
address. This attribute is only available from the Insights end point.
:type: int
.. attribute:: time_zone
The time zone associated with location, as specified by the `IANA Time
Zone Database <http://www.iana.org/time-zones>`_, e.g.,
"America/New_York".
:type: unicode
"""
_valid_attributes = set(['average_income', 'accuracy_radius', 'latitude',
'longitude', 'metro_code', 'population_density',
'postal_code', 'postal_confidence', 'time_zone'])
class MaxMind(Record):
"""Contains data related to your MaxMind account.
Attributes:
.. attribute:: queries_remaining
The number of remaining queries you have
for the end point you are calling.
:type: int
"""
_valid_attributes = set(['queries_remaining'])
class Postal(Record):
"""Contains data for the postal record associated with an IP address.
This class contains the postal data associated with an IP address.
This attribute is returned by ``city``, ``enterprise``, and ``insights``.
Attributes:
.. attribute:: code
The postal code of the location. Postal
codes are not available for all countries. In some countries, this will
only contain part of the postal code.
:type: unicode
.. attribute:: confidence
A value from 0-100 indicating
MaxMind's confidence that the postal code is correct. This attribute is
only available from the Insights end point and the GeoIP2 Enterprise
database.
:type: int
"""
_valid_attributes = set(['code', 'confidence'])
class Subdivision(PlaceRecord):
"""Contains data for the subdivisions associated with an IP address.
This class contains the subdivision data associated with an IP address.
This attribute is returned by ``city``, ``enterprise``, and ``insights``.
Attributes:
.. attribute:: confidence
This is a value from 0-100 indicating MaxMind's
confidence that the subdivision is correct. This attribute is only
available from the Insights end point and the GeoIP2 Enterprise
database.
:type: int
.. attribute:: geoname_id
This is a GeoName ID for the subdivision.
:type: int
.. attribute:: iso_code
This is a string up to three characters long
contain the subdivision portion of the `ISO 3166-2 code
<http://en.wikipedia.org/wiki/ISO_3166-2>`_.
:type: unicode
.. attribute:: name
The name of the subdivision based on the locales list passed to the
constructor.
:type: unicode
.. attribute:: names
A dictionary where the keys are locale codes and the
values are names
:type: dict
"""
_valid_attributes = set(['confidence', 'geoname_id', 'iso_code', 'names'])
class Subdivisions(tuple):
"""A tuple-like collection of subdivisions associated with an IP address.
This class contains the subdivisions of the country associated with the
IP address from largest to smallest.
For instance, the response for Oxford in the United Kingdom would have
England as the first element and Oxfordshire as the second element.
This attribute is returned by ``city``, ``enterprise``, and ``insights``.
"""
def __new__(cls, locales, *subdivisions):
subdivisions = [Subdivision(locales, **x) for x in subdivisions]
obj = super(cls, Subdivisions).__new__(cls, subdivisions)
return obj
def __init__(self, locales, *subdivisions): # pylint:disable=W0613
self._locales = locales
super(Subdivisions, self).__init__()
@property
def most_specific(self):
"""The most specific (smallest) subdivision available.
If there are no :py:class:`Subdivision` objects for the response,
this returns an empty :py:class:`Subdivision`.
:type: :py:class:`Subdivision`
"""
try:
return self[-1]
except IndexError:
return Subdivision(self._locales)
class Traits(Record):
"""Contains data for the traits record associated with an IP address.
This class contains the traits data associated with an IP address.
This class has the following attributes:
.. attribute:: autonomous_system_number
The `autonomous system
number <http://en.wikipedia.org/wiki/Autonomous_system_(Internet)>`_
associated with the IP address. This attribute is only available from
the City and Insights web service end points and the GeoIP2 Enterprise
database.
:type: int
.. attribute:: autonomous_system_organization
The organization associated with the registered `autonomous system
number <http://en.wikipedia.org/wiki/Autonomous_system_(Internet)>`_ for
the IP address. This attribute is only available from the City and
Insights web service end points and the GeoIP2 Enterprise database.
:type: unicode
.. attribute:: connection_type
The connection type may take the following values:
- Dialup
- Cable/DSL
- Corporate
- Cellular
Additional values may be added in the future.
This attribute is only available in the GeoIP2 Enterprise database.
:type: unicode
.. attribute:: domain
The second level domain associated with the
IP address. This will be something like "example.com" or
"example.co.uk", not "foo.example.com". This attribute is only available
from the City and Insights web service end points and the GeoIP2
Enterprise database.
:type: unicode
.. attribute:: ip_address
The IP address that the data in the model
is for. If you performed a "me" lookup against the web service, this
will be the externally routable IP address for the system the code is
running on. If the system is behind a NAT, this may differ from the IP
address locally assigned to it.
:type: unicode
.. attribute:: is_anonymous_proxy
This is true if the IP is an anonymous
proxy. See http://dev.maxmind.com/faq/geoip#anonproxy for further
details.
:type: bool
.. deprecated:: 2.2.0
Use our our `GeoIP2 Anonymous IP database
<https://www.maxmind.com/en/geoip2-anonymous-ip-database GeoIP2>`_
instead.
.. attribute:: is_legitimate_proxy
This attribute is true if MaxMind believes this IP address to be a
legitimate proxy, such as an internal VPN used by a corporation. This
attribute is only available in the GeoIP2 Enterprise database.
:type: bool
.. attribute:: is_satellite_provider
This is true if the IP address is from a satellite provider that
provides service to multiple countries.
:type: bool
.. deprecated:: 2.2.0
Due to the increased coverage by mobile carriers, very few
satellite providers now serve multiple countries. As a result, the
output does not provide sufficiently relevant data for us to maintain
it.
.. attribute:: isp
The name of the ISP associated with the IP address. This attribute is
only available from the City and Insights web service end points and the
GeoIP2 Enterprise database.
:type: unicode
.. attribute:: organization
The name of the organization associated with the IP address. This
attribute is only available from the City and Insights web service end
points and the GeoIP2 Enterprise database.
:type: unicode
.. attribute:: user_type
The user type associated with the IP
address. This can be one of the following values:
* business
* cafe
* cellular
* college
* content_delivery_network
* dialup
* government
* hosting
* library
* military
* residential
* router
* school
* search_engine_spider
* traveler
This attribute is only available from the Insights end point and the
GeoIP2 Enterprise database.
:type: unicode
"""
_valid_attributes = set(
['autonomous_system_number', 'autonomous_system_organization',
'connection_type', 'domain', 'is_anonymous_proxy',
'is_legitimate_proxy', 'is_satellite_provider', 'isp', 'ip_address',
'organization', 'user_type'])
def __init__(self, **kwargs):
for k in ['is_anonymous_proxy', 'is_legitimate_proxy',
'is_satellite_provider']:
kwargs[k] = bool(kwargs.get(k, False))
super(Traits, self).__init__(**kwargs)

View file

@ -1,219 +0,0 @@
"""
============================
WebServices Client API
============================
This class provides a client API for all the GeoIP2 Precision web service end
points. The end points are Country, City, and Insights. Each end point returns
a different set of data about an IP address, with Country returning the least
data and Insights the most.
Each web service end point is represented by a different model class, and
these model classes in turn contain multiple record classes. The record
classes have attributes which contain data about the IP address.
If the web service does not return a particular piece of data for an IP
address, the associated attribute is not populated.
The web service may not return any information for an entire record, in which
case all of the attributes for that record class will be empty.
SSL
---
Requests to the GeoIP2 Precision web service are always made with SSL.
"""
import requests
from requests.utils import default_user_agent
import geoip2
import geoip2.models
from .compat import compat_ip_address
from .errors import (AddressNotFoundError, AuthenticationError, GeoIP2Error,
HTTPError, InvalidRequestError, OutOfQueriesError,
PermissionRequiredError)
class Client(object):
"""Creates a new client object.
It accepts the following required arguments:
:param user_id: Your MaxMind User ID.
:param license_key: Your MaxMind license key.
Go to https://www.maxmind.com/en/my_license_key to see your MaxMind
User ID and license key.
The following keyword arguments are also accepted:
:param host: The hostname to make a request against. This defaults to
"geoip.maxmind.com". In most cases, you should not need to set this
explicitly.
:param locales: This is list of locale codes. This argument will be
passed on to record classes to use when their name properties are
called. The default value is ['en'].
The order of the locales is significant. When a record class has
multiple names (country, city, etc.), its name property will return
the name in the first locale that has one.
Note that the only locale which is always present in the GeoIP2
data is "en". If you do not include this locale, the name property
may end up returning None even when the record has an English name.
Currently, the valid locale codes are:
* de -- German
* en -- English names may still include accented characters if that is
the accepted spelling in English. In other words, English does not
mean ASCII.
* es -- Spanish
* fr -- French
* ja -- Japanese
* pt-BR -- Brazilian Portuguese
* ru -- Russian
* zh-CN -- Simplified Chinese.
"""
def __init__(self,
user_id,
license_key,
host='geoip.maxmind.com',
locales=None,
timeout=None):
"""Construct a Client."""
# pylint: disable=too-many-arguments
if locales is None:
locales = ['en']
self._locales = locales
self._user_id = user_id
self._license_key = license_key
self._base_uri = 'https://%s/geoip/v2.1' % host
self._timeout = timeout
def city(self, ip_address='me'):
"""Call GeoIP2 Precision City endpoint with the specified IP.
:param ip_address: IPv4 or IPv6 address as a string. If no
address is provided, the address that the web service is
called from will be used.
:returns: :py:class:`geoip2.models.City` object
"""
return self._response_for('city', geoip2.models.City, ip_address)
def country(self, ip_address='me'):
"""Call the GeoIP2 Country endpoint with the specified IP.
:param ip_address: IPv4 or IPv6 address as a string. If no address
is provided, the address that the web service is called from will
be used.
:returns: :py:class:`geoip2.models.Country` object
"""
return self._response_for('country', geoip2.models.Country, ip_address)
def insights(self, ip_address='me'):
"""Call the GeoIP2 Precision: Insights endpoint with the specified IP.
:param ip_address: IPv4 or IPv6 address as a string. If no address
is provided, the address that the web service is called from will
be used.
:returns: :py:class:`geoip2.models.Insights` object
"""
return self._response_for('insights', geoip2.models.Insights,
ip_address)
def _response_for(self, path, model_class, ip_address):
if ip_address != 'me':
ip_address = str(compat_ip_address(ip_address))
uri = '/'.join([self._base_uri, path, ip_address])
response = requests.get(uri,
auth=(self._user_id, self._license_key),
headers={'Accept': 'application/json',
'User-Agent': self._user_agent()},
timeout=self._timeout)
if response.status_code == 200:
body = self._handle_success(response, uri)
return model_class(body, locales=self._locales)
else:
self._handle_error(response, uri)
def _user_agent(self):
return 'GeoIP2 Python Client v%s (%s)' % (geoip2.__version__,
default_user_agent())
def _handle_success(self, response, uri):
try:
return response.json()
except ValueError as ex:
raise GeoIP2Error('Received a 200 response for %(uri)s'
' but could not decode the response as '
'JSON: ' % locals() + ', '.join(ex.args), 200,
uri)
def _handle_error(self, response, uri):
status = response.status_code
if 400 <= status < 500:
self._handle_4xx_status(response, status, uri)
elif 500 <= status < 600:
self._handle_5xx_status(status, uri)
else:
self._handle_non_200_status(status, uri)
def _handle_4xx_status(self, response, status, uri):
if not response.content:
raise HTTPError('Received a %(status)i error for %(uri)s '
'with no body.' % locals(), status, uri)
elif response.headers['Content-Type'].find('json') == -1:
raise HTTPError('Received a %i for %s with the following '
'body: %s' % (status, uri, response.content),
status, uri)
try:
body = response.json()
except ValueError as ex:
raise HTTPError(
'Received a %(status)i error for %(uri)s but it did'
' not include the expected JSON body: ' % locals() +
', '.join(ex.args), status, uri)
else:
if 'code' in body and 'error' in body:
self._handle_web_service_error(
body.get('error'), body.get('code'), status, uri)
else:
raise HTTPError(
'Response contains JSON but it does not specify '
'code or error keys', status, uri)
def _handle_web_service_error(self, message, code, status, uri):
if code in ('IP_ADDRESS_NOT_FOUND', 'IP_ADDRESS_RESERVED'):
raise AddressNotFoundError(message)
elif code in ('AUTHORIZATION_INVALID', 'LICENSE_KEY_REQUIRED',
'USER_ID_REQUIRED', 'USER_ID_UNKNOWN'):
raise AuthenticationError(message)
elif code in ('INSUFFICIENT_FUNDS', 'OUT_OF_QUERIES'):
raise OutOfQueriesError(message)
elif code == 'PERMISSION_REQUIRED':
raise PermissionRequiredError(message)
raise InvalidRequestError(message, code, status, uri)
def _handle_5xx_status(self, status, uri):
raise HTTPError('Received a server error (%(status)i) for '
'%(uri)s' % locals(), status, uri)
def _handle_non_200_status(self, status, uri):
raise HTTPError('Received a very surprising HTTP status '
'(%(status)i) for %(uri)s' % locals(), status, uri)

View file

@ -1,46 +0,0 @@
# pylint:disable=C0111
import os
import maxminddb.reader
try:
import maxminddb.extension
except ImportError:
maxminddb.extension = None
from maxminddb.const import (MODE_AUTO, MODE_MMAP, MODE_MMAP_EXT, MODE_FILE,
MODE_MEMORY)
from maxminddb.decoder import InvalidDatabaseError
def open_database(database, mode=MODE_AUTO):
"""Open a Maxmind DB database
Arguments:
database -- A path to a valid MaxMind DB file such as a GeoIP2
database file.
mode -- mode to open the database with. Valid mode are:
* MODE_MMAP_EXT - use the C extension with memory map.
* MODE_MMAP - read from memory map. Pure Python.
* MODE_FILE - read database as standard file. Pure Python.
* MODE_MEMORY - load database into memory. Pure Python.
* MODE_AUTO - tries MODE_MMAP_EXT, MODE_MMAP, MODE_FILE in that
order. Default mode.
"""
if (mode == MODE_AUTO and maxminddb.extension and
hasattr(maxminddb.extension, 'Reader')) or mode == MODE_MMAP_EXT:
return maxminddb.extension.Reader(database)
elif mode in (MODE_AUTO, MODE_MMAP, MODE_FILE, MODE_MEMORY):
return maxminddb.reader.Reader(database, mode)
raise ValueError('Unsupported open mode: {0}'.format(mode))
def Reader(database): # pylint: disable=invalid-name
"""This exists for backwards compatibility. Use open_database instead"""
return open_database(database)
__title__ = 'maxminddb'
__version__ = '1.2.1'
__author__ = 'Gregory Oschwald'
__license__ = 'Apache License, Version 2.0'
__copyright__ = 'Copyright 2014 Maxmind, Inc.'

View file

@ -1,33 +0,0 @@
import sys
import ipaddress
# pylint: skip-file
if sys.version_info[0] == 2:
def compat_ip_address(address):
if isinstance(address, bytes):
address = address.decode()
return ipaddress.ip_address(address)
int_from_byte = ord
FileNotFoundError = IOError
def int_from_bytes(b):
if b:
return int(b.encode("hex"), 16)
return 0
byte_from_int = chr
else:
def compat_ip_address(address):
return ipaddress.ip_address(address)
int_from_byte = lambda x: x
FileNotFoundError = FileNotFoundError
int_from_bytes = lambda x: int.from_bytes(x, 'big')
byte_from_int = lambda x: bytes([x])

View file

@ -1,7 +0,0 @@
"""Constants used in the API"""
MODE_AUTO = 0
MODE_MMAP_EXT = 1
MODE_MMAP = 2
MODE_FILE = 4
MODE_MEMORY = 8

View file

@ -1,173 +0,0 @@
"""
maxminddb.decoder
~~~~~~~~~~~~~~~~~
This package contains code for decoding the MaxMind DB data section.
"""
from __future__ import unicode_literals
import struct
from maxminddb.compat import byte_from_int, int_from_bytes
from maxminddb.errors import InvalidDatabaseError
class Decoder(object): # pylint: disable=too-few-public-methods
"""Decoder for the data section of the MaxMind DB"""
def __init__(self, database_buffer, pointer_base=0, pointer_test=False):
"""Created a Decoder for a MaxMind DB
Arguments:
database_buffer -- an mmap'd MaxMind DB file.
pointer_base -- the base number to use when decoding a pointer
pointer_test -- used for internal unit testing of pointer code
"""
self._pointer_test = pointer_test
self._buffer = database_buffer
self._pointer_base = pointer_base
def _decode_array(self, size, offset):
array = []
for _ in range(size):
(value, offset) = self.decode(offset)
array.append(value)
return array, offset
def _decode_boolean(self, size, offset):
return size != 0, offset
def _decode_bytes(self, size, offset):
new_offset = offset + size
return self._buffer[offset:new_offset], new_offset
# pylint: disable=no-self-argument
# |-> I am open to better ways of doing this as long as it doesn't involve
# lots of code duplication.
def _decode_packed_type(type_code, type_size, pad=False):
# pylint: disable=protected-access, missing-docstring
def unpack_type(self, size, offset):
if not pad:
self._verify_size(size, type_size)
new_offset = offset + type_size
packed_bytes = self._buffer[offset:new_offset]
if pad:
packed_bytes = packed_bytes.rjust(type_size, b'\x00')
(value,) = struct.unpack(type_code, packed_bytes)
return value, new_offset
return unpack_type
def _decode_map(self, size, offset):
container = {}
for _ in range(size):
(key, offset) = self.decode(offset)
(value, offset) = self.decode(offset)
container[key] = value
return container, offset
_pointer_value_offset = {
1: 0,
2: 2048,
3: 526336,
4: 0,
}
def _decode_pointer(self, size, offset):
pointer_size = ((size >> 3) & 0x3) + 1
new_offset = offset + pointer_size
pointer_bytes = self._buffer[offset:new_offset]
packed = pointer_bytes if pointer_size == 4 else struct.pack(
b'!c', byte_from_int(size & 0x7)) + pointer_bytes
unpacked = int_from_bytes(packed)
pointer = unpacked + self._pointer_base + \
self._pointer_value_offset[pointer_size]
if self._pointer_test:
return pointer, new_offset
(value, _) = self.decode(pointer)
return value, new_offset
def _decode_uint(self, size, offset):
new_offset = offset + size
uint_bytes = self._buffer[offset:new_offset]
return int_from_bytes(uint_bytes), new_offset
def _decode_utf8_string(self, size, offset):
new_offset = offset + size
return self._buffer[offset:new_offset].decode('utf-8'), new_offset
_type_decoder = {
1: _decode_pointer,
2: _decode_utf8_string,
3: _decode_packed_type(b'!d', 8), # double,
4: _decode_bytes,
5: _decode_uint, # uint16
6: _decode_uint, # uint32
7: _decode_map,
8: _decode_packed_type(b'!i', 4, pad=True), # int32
9: _decode_uint, # uint64
10: _decode_uint, # uint128
11: _decode_array,
14: _decode_boolean,
15: _decode_packed_type(b'!f', 4), # float,
}
def decode(self, offset):
"""Decode a section of the data section starting at offset
Arguments:
offset -- the location of the data structure to decode
"""
new_offset = offset + 1
(ctrl_byte,) = struct.unpack(b'!B', self._buffer[offset:new_offset])
type_num = ctrl_byte >> 5
# Extended type
if not type_num:
(type_num, new_offset) = self._read_extended(new_offset)
if type_num not in self._type_decoder:
raise InvalidDatabaseError('Unexpected type number ({type}) '
'encountered'.format(type=type_num))
(size, new_offset) = self._size_from_ctrl_byte(
ctrl_byte, new_offset, type_num)
return self._type_decoder[type_num](self, size, new_offset)
def _read_extended(self, offset):
(next_byte,) = struct.unpack(b'!B', self._buffer[offset:offset + 1])
type_num = next_byte + 7
if type_num < 7:
raise InvalidDatabaseError(
'Something went horribly wrong in the decoder. An '
'extended type resolved to a type number < 8 '
'({type})'.format(type=type_num))
return type_num, offset + 1
def _verify_size(self, expected, actual):
if expected != actual:
raise InvalidDatabaseError(
'The MaxMind DB file\'s data section contains bad data '
'(unknown data type or corrupt data)'
)
def _size_from_ctrl_byte(self, ctrl_byte, offset, type_num):
size = ctrl_byte & 0x1f
if type_num == 1:
return size, offset
bytes_to_read = 0 if size < 29 else size - 28
new_offset = offset + bytes_to_read
size_bytes = self._buffer[offset:new_offset]
# Using unpack rather than int_from_bytes as it is about 200 lookups
# per second faster here.
if size == 29:
size = 29 + struct.unpack(b'!B', size_bytes)[0]
elif size == 30:
size = 285 + struct.unpack(b'!H', size_bytes)[0]
elif size > 30:
size = struct.unpack(
b'!I', size_bytes.rjust(4, b'\x00'))[0] + 65821
return size, new_offset

View file

@ -1,11 +0,0 @@
"""
maxminddb.errors
~~~~~~~~~~~~~~~~
This module contains custom errors for the MaxMind DB reader
"""
class InvalidDatabaseError(RuntimeError):
"""This error is thrown when unexpected data is found in the database."""

View file

@ -1,570 +0,0 @@
#include <Python.h>
#include <maxminddb.h>
#include "structmember.h"
#define __STDC_FORMAT_MACROS
#include <inttypes.h>
static PyTypeObject Reader_Type;
static PyTypeObject Metadata_Type;
static PyObject *MaxMindDB_error;
typedef struct {
PyObject_HEAD /* no semicolon */
MMDB_s *mmdb;
} Reader_obj;
typedef struct {
PyObject_HEAD /* no semicolon */
PyObject *binary_format_major_version;
PyObject *binary_format_minor_version;
PyObject *build_epoch;
PyObject *database_type;
PyObject *description;
PyObject *ip_version;
PyObject *languages;
PyObject *node_count;
PyObject *record_size;
} Metadata_obj;
static PyObject *from_entry_data_list(MMDB_entry_data_list_s **entry_data_list);
static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list);
static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list);
static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list);
#if PY_MAJOR_VERSION >= 3
#define MOD_INIT(name) PyMODINIT_FUNC PyInit_ ## name(void)
#define RETURN_MOD_INIT(m) return (m)
#define FILE_NOT_FOUND_ERROR PyExc_FileNotFoundError
#else
#define MOD_INIT(name) PyMODINIT_FUNC init ## name(void)
#define RETURN_MOD_INIT(m) return
#define PyInt_FromLong PyLong_FromLong
#define FILE_NOT_FOUND_ERROR PyExc_IOError
#endif
#ifdef __GNUC__
# define UNUSED(x) UNUSED_ ## x __attribute__((__unused__))
#else
# define UNUSED(x) UNUSED_ ## x
#endif
static int Reader_init(PyObject *self, PyObject *args, PyObject *kwds)
{
char *filename;
int mode = 0;
static char *kwlist[] = {"database", "mode", NULL};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|i", kwlist, &filename, &mode)) {
return -1;
}
if (mode != 0 && mode != 1) {
PyErr_Format(PyExc_ValueError, "Unsupported open mode (%i). Only "
"MODE_AUTO and MODE_MMAP_EXT are supported by this extension.",
mode);
return -1;
}
if (0 != access(filename, R_OK)) {
PyErr_Format(FILE_NOT_FOUND_ERROR,
"No such file or directory: '%s'",
filename);
return -1;
}
MMDB_s *mmdb = (MMDB_s *)malloc(sizeof(MMDB_s));
if (NULL == mmdb) {
PyErr_NoMemory();
return -1;
}
Reader_obj *mmdb_obj = (Reader_obj *)self;
if (!mmdb_obj) {
free(mmdb);
PyErr_NoMemory();
return -1;
}
uint16_t status = MMDB_open(filename, MMDB_MODE_MMAP, mmdb);
if (MMDB_SUCCESS != status) {
free(mmdb);
PyErr_Format(
MaxMindDB_error,
"Error opening database file (%s). Is this a valid MaxMind DB file?",
filename
);
return -1;
}
mmdb_obj->mmdb = mmdb;
return 0;
}
static PyObject *Reader_get(PyObject *self, PyObject *args)
{
char *ip_address = NULL;
Reader_obj *mmdb_obj = (Reader_obj *)self;
if (!PyArg_ParseTuple(args, "s", &ip_address)) {
return NULL;
}
MMDB_s *mmdb = mmdb_obj->mmdb;
if (NULL == mmdb) {
PyErr_SetString(PyExc_ValueError,
"Attempt to read from a closed MaxMind DB.");
return NULL;
}
int gai_error = 0;
int mmdb_error = MMDB_SUCCESS;
MMDB_lookup_result_s result =
MMDB_lookup_string(mmdb, ip_address, &gai_error,
&mmdb_error);
if (0 != gai_error) {
PyErr_Format(PyExc_ValueError,
"'%s' does not appear to be an IPv4 or IPv6 address.",
ip_address);
return NULL;
}
if (MMDB_SUCCESS != mmdb_error) {
PyObject *exception;
if (MMDB_IPV6_LOOKUP_IN_IPV4_DATABASE_ERROR == mmdb_error) {
exception = PyExc_ValueError;
} else {
exception = MaxMindDB_error;
}
PyErr_Format(exception, "Error looking up %s. %s",
ip_address, MMDB_strerror(mmdb_error));
return NULL;
}
if (!result.found_entry) {
Py_RETURN_NONE;
}
MMDB_entry_data_list_s *entry_data_list = NULL;
int status = MMDB_get_entry_data_list(&result.entry, &entry_data_list);
if (MMDB_SUCCESS != status) {
PyErr_Format(MaxMindDB_error,
"Error while looking up data for %s. %s",
ip_address, MMDB_strerror(status));
MMDB_free_entry_data_list(entry_data_list);
return NULL;
}
MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
PyObject *py_obj = from_entry_data_list(&entry_data_list);
MMDB_free_entry_data_list(original_entry_data_list);
return py_obj;
}
static PyObject *Reader_metadata(PyObject *self, PyObject *UNUSED(args))
{
Reader_obj *mmdb_obj = (Reader_obj *)self;
if (NULL == mmdb_obj->mmdb) {
PyErr_SetString(PyExc_IOError,
"Attempt to read from a closed MaxMind DB.");
return NULL;
}
MMDB_entry_data_list_s *entry_data_list;
MMDB_get_metadata_as_entry_data_list(mmdb_obj->mmdb, &entry_data_list);
MMDB_entry_data_list_s *original_entry_data_list = entry_data_list;
PyObject *metadata_dict = from_entry_data_list(&entry_data_list);
MMDB_free_entry_data_list(original_entry_data_list);
if (NULL == metadata_dict || !PyDict_Check(metadata_dict)) {
PyErr_SetString(MaxMindDB_error,
"Error decoding metadata.");
return NULL;
}
PyObject *args = PyTuple_New(0);
if (NULL == args) {
Py_DECREF(metadata_dict);
return NULL;
}
PyObject *metadata = PyObject_Call((PyObject *)&Metadata_Type, args,
metadata_dict);
Py_DECREF(metadata_dict);
return metadata;
}
static PyObject *Reader_close(PyObject *self, PyObject *UNUSED(args))
{
Reader_obj *mmdb_obj = (Reader_obj *)self;
if (NULL != mmdb_obj->mmdb) {
MMDB_close(mmdb_obj->mmdb);
free(mmdb_obj->mmdb);
mmdb_obj->mmdb = NULL;
}
Py_RETURN_NONE;
}
static void Reader_dealloc(PyObject *self)
{
Reader_obj *obj = (Reader_obj *)self;
if (NULL != obj->mmdb) {
Reader_close(self, NULL);
}
PyObject_Del(self);
}
static int Metadata_init(PyObject *self, PyObject *args, PyObject *kwds)
{
PyObject
*binary_format_major_version,
*binary_format_minor_version,
*build_epoch,
*database_type,
*description,
*ip_version,
*languages,
*node_count,
*record_size;
static char *kwlist[] = {
"binary_format_major_version",
"binary_format_minor_version",
"build_epoch",
"database_type",
"description",
"ip_version",
"languages",
"node_count",
"record_size",
NULL
};
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOOOO", kwlist,
&binary_format_major_version,
&binary_format_minor_version,
&build_epoch,
&database_type,
&description,
&ip_version,
&languages,
&node_count,
&record_size)) {
return -1;
}
Metadata_obj *obj = (Metadata_obj *)self;
obj->binary_format_major_version = binary_format_major_version;
obj->binary_format_minor_version = binary_format_minor_version;
obj->build_epoch = build_epoch;
obj->database_type = database_type;
obj->description = description;
obj->ip_version = ip_version;
obj->languages = languages;
obj->node_count = node_count;
obj->record_size = record_size;
Py_INCREF(obj->binary_format_major_version);
Py_INCREF(obj->binary_format_minor_version);
Py_INCREF(obj->build_epoch);
Py_INCREF(obj->database_type);
Py_INCREF(obj->description);
Py_INCREF(obj->ip_version);
Py_INCREF(obj->languages);
Py_INCREF(obj->node_count);
Py_INCREF(obj->record_size);
return 0;
}
static void Metadata_dealloc(PyObject *self)
{
Metadata_obj *obj = (Metadata_obj *)self;
Py_DECREF(obj->binary_format_major_version);
Py_DECREF(obj->binary_format_minor_version);
Py_DECREF(obj->build_epoch);
Py_DECREF(obj->database_type);
Py_DECREF(obj->description);
Py_DECREF(obj->ip_version);
Py_DECREF(obj->languages);
Py_DECREF(obj->node_count);
Py_DECREF(obj->record_size);
PyObject_Del(self);
}
static PyObject *from_entry_data_list(MMDB_entry_data_list_s **entry_data_list)
{
if (NULL == entry_data_list || NULL == *entry_data_list) {
PyErr_SetString(
MaxMindDB_error,
"Error while looking up data. Your database may be corrupt or you have found a bug in libmaxminddb."
);
return NULL;
}
switch ((*entry_data_list)->entry_data.type) {
case MMDB_DATA_TYPE_MAP:
return from_map(entry_data_list);
case MMDB_DATA_TYPE_ARRAY:
return from_array(entry_data_list);
case MMDB_DATA_TYPE_UTF8_STRING:
return PyUnicode_FromStringAndSize(
(*entry_data_list)->entry_data.utf8_string,
(*entry_data_list)->entry_data.data_size
);
case MMDB_DATA_TYPE_BYTES:
return PyByteArray_FromStringAndSize(
(const char *)(*entry_data_list)->entry_data.bytes,
(Py_ssize_t)(*entry_data_list)->entry_data.data_size);
case MMDB_DATA_TYPE_DOUBLE:
return PyFloat_FromDouble((*entry_data_list)->entry_data.double_value);
case MMDB_DATA_TYPE_FLOAT:
return PyFloat_FromDouble((*entry_data_list)->entry_data.float_value);
case MMDB_DATA_TYPE_UINT16:
return PyLong_FromLong( (*entry_data_list)->entry_data.uint16);
case MMDB_DATA_TYPE_UINT32:
return PyLong_FromLong((*entry_data_list)->entry_data.uint32);
case MMDB_DATA_TYPE_BOOLEAN:
return PyBool_FromLong((*entry_data_list)->entry_data.boolean);
case MMDB_DATA_TYPE_UINT64:
return PyLong_FromUnsignedLongLong(
(*entry_data_list)->entry_data.uint64);
case MMDB_DATA_TYPE_UINT128:
return from_uint128(*entry_data_list);
case MMDB_DATA_TYPE_INT32:
return PyLong_FromLong((*entry_data_list)->entry_data.int32);
default:
PyErr_Format(MaxMindDB_error,
"Invalid data type arguments: %d",
(*entry_data_list)->entry_data.type);
return NULL;
}
return NULL;
}
static PyObject *from_map(MMDB_entry_data_list_s **entry_data_list)
{
PyObject *py_obj = PyDict_New();
if (NULL == py_obj) {
PyErr_NoMemory();
return NULL;
}
const uint32_t map_size = (*entry_data_list)->entry_data.data_size;
uint i;
// entry_data_list cannot start out NULL (see from_entry_data_list). We
// check it in the loop because it may become NULL.
// coverity[check_after_deref]
for (i = 0; i < map_size && entry_data_list; i++) {
*entry_data_list = (*entry_data_list)->next;
PyObject *key = PyUnicode_FromStringAndSize(
(char *)(*entry_data_list)->entry_data.utf8_string,
(*entry_data_list)->entry_data.data_size
);
*entry_data_list = (*entry_data_list)->next;
PyObject *value = from_entry_data_list(entry_data_list);
if (NULL == value) {
Py_DECREF(key);
Py_DECREF(py_obj);
return NULL;
}
PyDict_SetItem(py_obj, key, value);
Py_DECREF(value);
Py_DECREF(key);
}
return py_obj;
}
static PyObject *from_array(MMDB_entry_data_list_s **entry_data_list)
{
const uint32_t size = (*entry_data_list)->entry_data.data_size;
PyObject *py_obj = PyList_New(size);
if (NULL == py_obj) {
PyErr_NoMemory();
return NULL;
}
uint i;
// entry_data_list cannot start out NULL (see from_entry_data_list). We
// check it in the loop because it may become NULL.
// coverity[check_after_deref]
for (i = 0; i < size && entry_data_list; i++) {
*entry_data_list = (*entry_data_list)->next;
PyObject *value = from_entry_data_list(entry_data_list);
if (NULL == value) {
Py_DECREF(py_obj);
return NULL;
}
// PyList_SetItem 'steals' the reference
PyList_SetItem(py_obj, i, value);
}
return py_obj;
}
static PyObject *from_uint128(const MMDB_entry_data_list_s *entry_data_list)
{
uint64_t high = 0;
uint64_t low = 0;
#if MMDB_UINT128_IS_BYTE_ARRAY
int i;
for (i = 0; i < 8; i++) {
high = (high << 8) | entry_data_list->entry_data.uint128[i];
}
for (i = 8; i < 16; i++) {
low = (low << 8) | entry_data_list->entry_data.uint128[i];
}
#else
high = entry_data_list->entry_data.uint128 >> 64;
low = (uint64_t)entry_data_list->entry_data.uint128;
#endif
char *num_str = malloc(33);
if (NULL == num_str) {
PyErr_NoMemory();
return NULL;
}
snprintf(num_str, 33, "%016" PRIX64 "%016" PRIX64, high, low);
PyObject *py_obj = PyLong_FromString(num_str, NULL, 16);
free(num_str);
return py_obj;
}
static PyMethodDef Reader_methods[] = {
{ "get", Reader_get, METH_VARARGS,
"Get record for IP address" },
{ "metadata", Reader_metadata, METH_NOARGS,
"Returns metadata object for database" },
{ "close", Reader_close, METH_NOARGS, "Closes database"},
{ NULL, NULL, 0, NULL }
};
static PyTypeObject Reader_Type = {
PyVarObject_HEAD_INIT(NULL, 0)
.tp_basicsize = sizeof(Reader_obj),
.tp_dealloc = Reader_dealloc,
.tp_doc = "Reader object",
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_methods = Reader_methods,
.tp_name = "Reader",
.tp_init = Reader_init,
};
static PyMethodDef Metadata_methods[] = {
{ NULL, NULL, 0, NULL }
};
/* *INDENT-OFF* */
static PyMemberDef Metadata_members[] = {
{ "binary_format_major_version", T_OBJECT, offsetof(
Metadata_obj, binary_format_major_version), READONLY, NULL },
{ "binary_format_minor_version", T_OBJECT, offsetof(
Metadata_obj, binary_format_minor_version), READONLY, NULL },
{ "build_epoch", T_OBJECT, offsetof(Metadata_obj, build_epoch),
READONLY, NULL },
{ "database_type", T_OBJECT, offsetof(Metadata_obj, database_type),
READONLY, NULL },
{ "description", T_OBJECT, offsetof(Metadata_obj, description),
READONLY, NULL },
{ "ip_version", T_OBJECT, offsetof(Metadata_obj, ip_version),
READONLY, NULL },
{ "languages", T_OBJECT, offsetof(Metadata_obj, languages), READONLY,
NULL },
{ "node_count", T_OBJECT, offsetof(Metadata_obj, node_count),
READONLY, NULL },
{ "record_size", T_OBJECT, offsetof(Metadata_obj, record_size),
READONLY, NULL },
{ NULL, 0, 0, 0, NULL }
};
/* *INDENT-ON* */
static PyTypeObject Metadata_Type = {
PyVarObject_HEAD_INIT(NULL, 0)
.tp_basicsize = sizeof(Metadata_obj),
.tp_dealloc = Metadata_dealloc,
.tp_doc = "Metadata object",
.tp_flags = Py_TPFLAGS_DEFAULT,
.tp_members = Metadata_members,
.tp_methods = Metadata_methods,
.tp_name = "Metadata",
.tp_init = Metadata_init
};
static PyMethodDef MaxMindDB_methods[] = {
{ NULL, NULL, 0, NULL }
};
#if PY_MAJOR_VERSION >= 3
static struct PyModuleDef MaxMindDB_module = {
PyModuleDef_HEAD_INIT,
.m_name = "extension",
.m_doc = "This is a C extension to read MaxMind DB file format",
.m_methods = MaxMindDB_methods,
};
#endif
MOD_INIT(extension){
PyObject *m;
#if PY_MAJOR_VERSION >= 3
m = PyModule_Create(&MaxMindDB_module);
#else
m = Py_InitModule("extension", MaxMindDB_methods);
#endif
if (!m) {
RETURN_MOD_INIT(NULL);
}
Reader_Type.tp_new = PyType_GenericNew;
if (PyType_Ready(&Reader_Type)) {
RETURN_MOD_INIT(NULL);
}
Py_INCREF(&Reader_Type);
PyModule_AddObject(m, "Reader", (PyObject *)&Reader_Type);
Metadata_Type.tp_new = PyType_GenericNew;
if (PyType_Ready(&Metadata_Type)) {
RETURN_MOD_INIT(NULL);
}
PyModule_AddObject(m, "extension", (PyObject *)&Metadata_Type);
PyObject* error_mod = PyImport_ImportModule("maxminddb.errors");
if (error_mod == NULL) {
RETURN_MOD_INIT(NULL);
}
MaxMindDB_error = PyObject_GetAttrString(error_mod, "InvalidDatabaseError");
Py_DECREF(error_mod);
if (MaxMindDB_error == NULL) {
RETURN_MOD_INIT(NULL);
}
Py_INCREF(MaxMindDB_error);
/* We primarily add it to the module for backwards compatibility */
PyModule_AddObject(m, "InvalidDatabaseError", MaxMindDB_error);
RETURN_MOD_INIT(m);
}

View file

@ -1,66 +0,0 @@
"""For internal use only. It provides a slice-like file reader."""
import os
try:
# pylint: disable=no-name-in-module
from multiprocessing import Lock
except ImportError:
from threading import Lock
class FileBuffer(object):
"""A slice-able file reader"""
def __init__(self, database):
self._handle = open(database, 'rb')
self._size = os.fstat(self._handle.fileno()).st_size
if not hasattr(os, 'pread'):
self._lock = Lock()
def __getitem__(self, key):
if isinstance(key, slice):
return self._read(key.stop - key.start, key.start)
elif isinstance(key, int):
return self._read(1, key)
else:
raise TypeError("Invalid argument type.")
def rfind(self, needle, start):
"""Reverse find needle from start"""
pos = self._read(self._size - start - 1, start).rfind(needle)
if pos == -1:
return pos
return start + pos
def size(self):
"""Size of file"""
return self._size
def close(self):
"""Close file"""
self._handle.close()
if hasattr(os, 'pread'):
def _read(self, buffersize, offset):
"""read that uses pread"""
# pylint: disable=no-member
return os.pread(self._handle.fileno(), buffersize, offset)
else:
def _read(self, buffersize, offset):
"""read with a lock
This lock is necessary as after a fork, the different processes
will share the same file table entry, even if we dup the fd, and
as such the same offsets. There does not appear to be a way to
duplicate the file table entry and we cannot re-open based on the
original path as that file may have replaced with another or
unlinked.
"""
with self._lock:
self._handle.seek(offset)
return self._handle.read(buffersize)

View file

@ -1,223 +0,0 @@
"""
maxminddb.reader
~~~~~~~~~~~~~~~~
This module contains the pure Python database reader and related classes.
"""
from __future__ import unicode_literals
try:
import mmap
except ImportError:
# pylint: disable=invalid-name
mmap = None
import struct
from maxminddb.compat import byte_from_int, int_from_byte, compat_ip_address
from maxminddb.const import MODE_AUTO, MODE_MMAP, MODE_FILE, MODE_MEMORY
from maxminddb.decoder import Decoder
from maxminddb.errors import InvalidDatabaseError
from maxminddb.file import FileBuffer
class Reader(object):
"""
Instances of this class provide a reader for the MaxMind DB format. IP
addresses can be looked up using the ``get`` method.
"""
_DATA_SECTION_SEPARATOR_SIZE = 16
_METADATA_START_MARKER = b"\xAB\xCD\xEFMaxMind.com"
_ipv4_start = None
def __init__(self, database, mode=MODE_AUTO):
"""Reader for the MaxMind DB file format
Arguments:
database -- A path to a valid MaxMind DB file such as a GeoIP2
database file.
mode -- mode to open the database with. Valid mode are:
* MODE_MMAP - read from memory map.
* MODE_FILE - read database as standard file.
* MODE_MEMORY - load database into memory.
* MODE_AUTO - tries MODE_MMAP and then MODE_FILE. Default.
"""
# pylint: disable=redefined-variable-type
if (mode == MODE_AUTO and mmap) or mode == MODE_MMAP:
with open(database, 'rb') as db_file:
self._buffer = mmap.mmap(
db_file.fileno(), 0, access=mmap.ACCESS_READ)
self._buffer_size = self._buffer.size()
elif mode in (MODE_AUTO, MODE_FILE):
self._buffer = FileBuffer(database)
self._buffer_size = self._buffer.size()
elif mode == MODE_MEMORY:
with open(database, 'rb') as db_file:
self._buffer = db_file.read()
self._buffer_size = len(self._buffer)
else:
raise ValueError('Unsupported open mode ({0}). Only MODE_AUTO, '
' MODE_FILE, and MODE_MEMORY are support by the pure Python '
'Reader'.format(mode))
metadata_start = self._buffer.rfind(self._METADATA_START_MARKER,
max(0, self._buffer_size
- 128 * 1024))
if metadata_start == -1:
self.close()
raise InvalidDatabaseError('Error opening database file ({0}). '
'Is this a valid MaxMind DB file?'
''.format(database))
metadata_start += len(self._METADATA_START_MARKER)
metadata_decoder = Decoder(self._buffer, metadata_start)
(metadata, _) = metadata_decoder.decode(metadata_start)
self._metadata = Metadata(
**metadata) # pylint: disable=bad-option-value
self._decoder = Decoder(self._buffer, self._metadata.search_tree_size
+ self._DATA_SECTION_SEPARATOR_SIZE)
def metadata(self):
"""Return the metadata associated with the MaxMind DB file"""
return self._metadata
def get(self, ip_address):
"""Return the record for the ip_address in the MaxMind DB
Arguments:
ip_address -- an IP address in the standard string notation
"""
address = compat_ip_address(ip_address)
if address.version == 6 and self._metadata.ip_version == 4:
raise ValueError('Error looking up {0}. You attempted to look up '
'an IPv6 address in an IPv4-only database.'.format(
ip_address))
pointer = self._find_address_in_tree(address)
return self._resolve_data_pointer(pointer) if pointer else None
def _find_address_in_tree(self, ip_address):
packed = ip_address.packed
bit_count = len(packed) * 8
node = self._start_node(bit_count)
for i in range(bit_count):
if node >= self._metadata.node_count:
break
bit = 1 & (int_from_byte(packed[i >> 3]) >> 7 - (i % 8))
node = self._read_node(node, bit)
if node == self._metadata.node_count:
# Record is empty
return 0
elif node > self._metadata.node_count:
return node
raise InvalidDatabaseError('Invalid node in search tree')
def _start_node(self, length):
if self._metadata.ip_version != 6 or length == 128:
return 0
# We are looking up an IPv4 address in an IPv6 tree. Skip over the
# first 96 nodes.
if self._ipv4_start:
return self._ipv4_start
node = 0
for _ in range(96):
if node >= self._metadata.node_count:
break
node = self._read_node(node, 0)
self._ipv4_start = node
return node
def _read_node(self, node_number, index):
base_offset = node_number * self._metadata.node_byte_size
record_size = self._metadata.record_size
if record_size == 24:
offset = base_offset + index * 3
node_bytes = b'\x00' + self._buffer[offset:offset + 3]
elif record_size == 28:
(middle,) = struct.unpack(
b'!B', self._buffer[base_offset + 3:base_offset + 4])
if index:
middle &= 0x0F
else:
middle = (0xF0 & middle) >> 4
offset = base_offset + index * 4
node_bytes = byte_from_int(
middle) + self._buffer[offset:offset + 3]
elif record_size == 32:
offset = base_offset + index * 4
node_bytes = self._buffer[offset:offset + 4]
else:
raise InvalidDatabaseError(
'Unknown record size: {0}'.format(record_size))
return struct.unpack(b'!I', node_bytes)[0]
def _resolve_data_pointer(self, pointer):
resolved = pointer - self._metadata.node_count + \
self._metadata.search_tree_size
if resolved > self._buffer_size:
raise InvalidDatabaseError(
"The MaxMind DB file's search tree is corrupt")
(data, _) = self._decoder.decode(resolved)
return data
def close(self):
"""Closes the MaxMind DB file and returns the resources to the system"""
# pylint: disable=unidiomatic-typecheck
if type(self._buffer) not in (str, bytes):
self._buffer.close()
class Metadata(object):
"""Metadata for the MaxMind DB reader"""
# pylint: disable=too-many-instance-attributes
def __init__(self, **kwargs):
"""Creates new Metadata object. kwargs are key/value pairs from spec"""
# Although I could just update __dict__, that is less obvious and it
# doesn't work well with static analysis tools and some IDEs
self.node_count = kwargs['node_count']
self.record_size = kwargs['record_size']
self.ip_version = kwargs['ip_version']
self.database_type = kwargs['database_type']
self.languages = kwargs['languages']
self.binary_format_major_version = kwargs[
'binary_format_major_version']
self.binary_format_minor_version = kwargs[
'binary_format_minor_version']
self.build_epoch = kwargs['build_epoch']
self.description = kwargs['description']
@property
def node_byte_size(self):
"""The size of a node in bytes"""
return self.record_size // 4
@property
def search_tree_size(self):
"""The size of the search tree"""
return self.node_count * self.node_byte_size
def __repr__(self):
args = ', '.join('%s=%r' % x for x in self.__dict__.items())
return '{module}.{class_name}({data})'.format(
module=self.__module__,
class_name=self.__class__.__name__,
data=args)

View file

@ -472,7 +472,7 @@ def initialize_scheduler():
pms_update_check_hours = CONFIG.PMS_UPDATE_CHECK_INTERVAL if 1 <= CONFIG.PMS_UPDATE_CHECK_INTERVAL else 24
schedule_job(versioncheck.check_update, 'Check GitHub for updates',
hours=0, minutes=github_minutes, seconds=0, args=(bool(CONFIG.PLEXPY_AUTO_UPDATE), True))
hours=0, minutes=github_minutes, seconds=0, args=(True, True))
backup_hours = CONFIG.BACKUP_INTERVAL if 1 <= CONFIG.BACKUP_INTERVAL <= 24 else 6
@ -480,15 +480,15 @@ def initialize_scheduler():
hours=backup_hours, minutes=0, seconds=0, args=(True, True))
schedule_job(config.make_backup, 'Backup Tautulli config',
hours=backup_hours, minutes=0, seconds=0, args=(True, True))
schedule_job(helpers.update_geoip_db, 'Update GeoLite2 database',
hours=12 * bool(CONFIG.GEOIP_DB_INSTALLED), minutes=0, seconds=0)
if WS_CONNECTED and CONFIG.PMS_IP and CONFIG.PMS_TOKEN:
schedule_job(plextv.get_server_resources, 'Refresh Plex server URLs',
hours=12 * (not bool(CONFIG.PMS_URL_MANUAL)), minutes=0, seconds=0)
pms_remote_access_seconds = CONFIG.REMOTE_ACCESS_PING_INTERVAL if 60 <= CONFIG.REMOTE_ACCESS_PING_INTERVAL else 60
schedule_job(activity_pinger.check_server_access, 'Check for Plex remote access',
hours=0, minutes=0, seconds=60 * bool(CONFIG.MONITOR_REMOTE_ACCESS))
hours=0, minutes=0, seconds=pms_remote_access_seconds * bool(CONFIG.MONITOR_REMOTE_ACCESS))
schedule_job(activity_pinger.check_server_updates, 'Check for Plex updates',
hours=pms_update_check_hours * bool(CONFIG.MONITOR_PMS_UPDATES), minutes=0, seconds=0)
@ -612,8 +612,8 @@ def dbcheck():
'CREATE TABLE IF NOT EXISTS sessions (id INTEGER PRIMARY KEY AUTOINCREMENT, session_key INTEGER, session_id TEXT, '
'transcode_key TEXT, rating_key INTEGER, section_id INTEGER, media_type TEXT, started INTEGER, stopped INTEGER, '
'paused_counter INTEGER DEFAULT 0, state TEXT, user_id INTEGER, user TEXT, friendly_name TEXT, '
'ip_address TEXT, machine_id TEXT, player TEXT, product TEXT, platform TEXT, title TEXT, parent_title TEXT, '
'grandparent_title TEXT, original_title TEXT, full_title TEXT, '
'ip_address TEXT, machine_id TEXT, bandwidth INTEGER, location TEXT, player TEXT, product TEXT, platform TEXT, '
'title TEXT, parent_title TEXT, grandparent_title TEXT, original_title TEXT, full_title TEXT, '
'media_index INTEGER, parent_media_index INTEGER, '
'thumb TEXT, parent_thumb TEXT, grandparent_thumb TEXT, year INTEGER, '
'parent_rating_key INTEGER, grandparent_rating_key INTEGER, '
@ -640,7 +640,13 @@ def dbcheck():
'live INTEGER, live_uuid TEXT, channel_call_sign TEXT, channel_identifier TEXT, channel_thumb TEXT, '
'secure INTEGER, relayed INTEGER, '
'buffer_count INTEGER DEFAULT 0, buffer_last_triggered INTEGER, last_paused INTEGER, watched INTEGER DEFAULT 0, '
'write_attempts INTEGER DEFAULT 0, raw_stream_info TEXT)'
'initial_stream INTEGER DEFAULT 1, write_attempts INTEGER DEFAULT 0, raw_stream_info TEXT)'
)
# sessions_continued table :: This is a temp table that keeps track of continued streaming sessions
c_db.execute(
'CREATE TABLE IF NOT EXISTS sessions_continued (id INTEGER PRIMARY KEY AUTOINCREMENT, '
'user_id INTEGER, machine_id TEXT, media_type TEXT, stopped INTEGER)'
)
# session_history table :: This is a history table which logs essential stream details
@ -1294,6 +1300,27 @@ def dbcheck():
'ALTER TABLE sessions ADD COLUMN guid TEXT'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT bandwidth FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN bandwidth INTEGER'
)
c_db.execute(
'ALTER TABLE sessions ADD COLUMN location TEXT'
)
# Upgrade sessions table from earlier versions
try:
c_db.execute('SELECT initial_stream FROM sessions')
except sqlite3.OperationalError:
logger.debug(u"Altering database. Updating database table sessions.")
c_db.execute(
'ALTER TABLE sessions ADD COLUMN initial_stream INTEGER DEFAULT 1'
)
# Upgrade session_history table from earlier versions
try:
c_db.execute('SELECT reference_id FROM session_history')

View file

@ -96,14 +96,14 @@ class ActivityHandler(object):
return None
def update_db_session(self, session=None):
def update_db_session(self, session=None, notify=False):
if session is None:
session = self.get_live_session()
if session:
# Update our session temp table values
ap = activity_processor.ActivityProcessor()
ap.write_session(session=session, notify=False)
ap.write_session(session=session, notify=notify)
self.set_session_state()
@ -133,10 +133,11 @@ class ActivityHandler(object):
% (str(session['session_key']), str(session['user_id']), session['username'],
str(session['rating_key']), session['full_title'], '[Live TV]' if session['live'] else ''))
plexpy.NOTIFY_QUEUE.put({'stream_data': session.copy(), 'notify_action': 'on_play'})
# Send notification after updating db
#plexpy.NOTIFY_QUEUE.put({'stream_data': session.copy(), 'notify_action': 'on_play'})
# Write the new session to our temp session table
self.update_db_session(session=session)
self.update_db_session(session=session, notify=True)
# Schedule a callback to force stop a stale stream 5 minutes later
schedule_callback('session_key-{}'.format(self.get_session_key()),

View file

@ -17,7 +17,6 @@ from __future__ import unicode_literals
from future.builtins import str
import threading
import time
import plexpy
if plexpy.PYTHON2:
@ -327,31 +326,27 @@ def check_server_access():
# Check for remote access
if server_response:
mapping_state = server_response['mapping_state']
mapping_error = server_response['mapping_error']
# Check if the port is mapped
if not mapping_state == 'mapped':
if server_response['reason']:
ext_ping_count += 1
logger.warn("Tautulli Monitor :: Plex remote access port not mapped, ping attempt %s." \
% str(ext_ping_count))
# Check if the port is open
elif mapping_error == 'unreachable':
ext_ping_count += 1
logger.warn("Tautulli Monitor :: Plex remote access port mapped, but mapping failed, ping attempt %s." \
logger.warn("Tautulli Monitor :: Remote access failed: %s, ping attempt %s." \
% (server_response['reason'], str(ext_ping_count)))
# Waiting for port mapping
elif server_response['mapping_state'] == 'waiting':
logger.warn("Tautulli Monitor :: Remote access waiting for port mapping, ping attempt %s." \
% str(ext_ping_count))
# Reset external ping counter
else:
if ext_ping_count >= plexpy.CONFIG.REMOTE_ACCESS_PING_THRESHOLD:
logger.info("Tautulli Monitor :: Plex remote access is back up.")
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_extup'})
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_extup', 'remote_access_info': server_response})
ext_ping_count = 0
if ext_ping_count == plexpy.CONFIG.REMOTE_ACCESS_PING_THRESHOLD:
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_extdown'})
plexpy.NOTIFY_QUEUE.put({'notify_action': 'on_extdown', 'remote_access_info': server_response})
def check_server_updates():

View file

@ -19,7 +19,6 @@ from future.builtins import object
from collections import defaultdict
import json
import time
import plexpy
if plexpy.PYTHON2:
@ -68,6 +67,8 @@ class ActivityProcessor(object):
'year': session.get('year', ''),
'friendly_name': session.get('friendly_name', ''),
'ip_address': session.get('ip_address', ''),
'bandwidth': session.get('bandwidth', 0),
'location': session.get('location', ''),
'player': session.get('player', ''),
'product': session.get('product', ''),
'platform': session.get('platform', ''),
@ -152,15 +153,20 @@ class ActivityProcessor(object):
result = self.db.upsert('sessions', values, keys)
if result == 'insert':
# Check if any notification agents have notifications enabled
if notify:
plexpy.NOTIFY_QUEUE.put({'stream_data': values.copy(), 'notify_action': 'on_play'})
# If it's our first write then time stamp it.
started = helpers.timestamp()
timestamp = {'started': started}
initial_stream = self.is_initial_stream(user_id=values['user_id'],
machine_id=values['machine_id'],
media_type=values['media_type'],
started=started)
timestamp = {'started': started, 'initial_stream': initial_stream}
self.db.upsert('sessions', timestamp, keys)
# Check if any notification agents have notifications enabled
if notify:
session.update(timestamp)
plexpy.NOTIFY_QUEUE.put({'stream_data': session.copy(), 'notify_action': 'on_play'})
# Add Live TV library if it hasn't been added
if values['live']:
libraries.add_live_tv_library()
@ -209,6 +215,12 @@ class ActivityProcessor(object):
state='stopped',
stopped=stopped)
if not is_import:
self.write_continued_session(user_id=session['user_id'],
machine_id=session['machine_id'],
media_type=session['media_type'],
stopped=stopped)
if str(session['rating_key']).isdigit() and session['media_type'] in ('movie', 'episode', 'track'):
logging_enabled = True
else:
@ -637,3 +649,16 @@ class ActivityProcessor(object):
self.db.action('UPDATE sessions SET watched = ?'
'WHERE session_key = ?',
[1, session_key])
def write_continued_session(self, user_id=None, machine_id=None, media_type=None, stopped=None):
keys = {'user_id': user_id, 'machine_id': machine_id, 'media_type': media_type}
values = {'stopped': stopped}
self.db.upsert(table_name='sessions_continued', key_dict=keys, value_dict=values)
def is_initial_stream(self, user_id=None, machine_id=None, media_type=None, started=None):
last_session = self.db.select_single('SELECT stopped '
'FROM sessions_continued '
'WHERE user_id = ? AND machine_id = ? AND media_type = ? '
'ORDER BY stopped DESC',
[user_id, machine_id, media_type])
return int(started - last_session.get('stopped', 0) >= plexpy.CONFIG.NOTIFY_CONTINUED_SESSION_THRESHOLD)

View file

@ -631,6 +631,12 @@ General optional parameters:
cherrypy.response.headers['Content-Type'] = 'image/jpeg'
return out['response']['data']
elif self._api_cmd == 'get_geoip_lookup':
# Remove nested data and put error message inside data for backwards compatibility
out['response']['data'] = out['response']['data'].get('data')
if not out['response']['data']:
out['response']['data'] = {'error': out['response']['message']}
if self._api_out_type == 'json':
cherrypy.response.headers['Content-Type'] = 'application/json;charset=UTF-8'
try:

View file

@ -224,8 +224,7 @@ SCHEDULER_LIST = [
'Refresh libraries list',
'Refresh Plex server URLs',
'Backup Tautulli database',
'Backup Tautulli config',
'Update GeoLite2 database'
'Backup Tautulli config'
]
DATE_TIME_FORMATS = [
@ -350,10 +349,13 @@ NOTIFICATION_PARAMETERS = [
{
'category': 'Stream Details',
'parameters': [
{'name': 'Streams', 'type': 'int', 'value': 'streams', 'description': 'The number of concurrent streams.'},
{'name': 'Direct Plays', 'type': 'int', 'value': 'direct_plays', 'description': 'The number of concurrent direct plays.'},
{'name': 'Direct Streams', 'type': 'int', 'value': 'direct_streams', 'description': 'The number of concurrent direct streams.'},
{'name': 'Transcodes', 'type': 'int', 'value': 'transcodes', 'description': 'The number of concurrent transcodes.'},
{'name': 'Streams', 'type': 'int', 'value': 'streams', 'description': 'The total number of concurrent streams.'},
{'name': 'Direct Plays', 'type': 'int', 'value': 'direct_plays', 'description': 'The total number of concurrent direct plays.'},
{'name': 'Direct Streams', 'type': 'int', 'value': 'direct_streams', 'description': 'The total number of concurrent direct streams.'},
{'name': 'Transcodes', 'type': 'int', 'value': 'transcodes', 'description': 'The total number of concurrent transcodes.'},
{'name': 'Total Bandwidth', 'type': 'int', 'value': 'total_bandwidth', 'description': 'The total Plex Streaming Brain reserved bandwidth (in kbps).', 'help_text': 'not the used bandwidth'},
{'name': 'LAN Bandwidth', 'type': 'int', 'value': 'lan_bandwidth', 'description': 'The total Plex Streaming Brain reserved LAN bandwidth (in kbps).', 'help_text': 'not the used bandwidth'},
{'name': 'WAN Bandwidth', 'type': 'int', 'value': 'wan_bandwidth', 'description': 'The total Plex Streaming Brain reserved WAN bandwidth (in kbps).', 'help_text': 'not the used bandwidth'},
{'name': 'User Streams', 'type': 'int', 'value': 'user_streams', 'description': 'The number of concurrent streams by the user streaming.'},
{'name': 'User Direct Plays', 'type': 'int', 'value': 'user_direct_plays', 'description': 'The number of concurrent direct plays by the user streaming.'},
{'name': 'User Direct Streams', 'type': 'int', 'value': 'user_direct_streams', 'description': 'The number of concurrent direct streams by the user streaming.'},
@ -361,10 +363,12 @@ NOTIFICATION_PARAMETERS = [
{'name': 'User', 'type': 'str', 'value': 'user', 'description': 'The friendly name of the user streaming.'},
{'name': 'Username', 'type': 'str', 'value': 'username', 'description': 'The username of the user streaming.'},
{'name': 'User Email', 'type': 'str', 'value': 'user_email', 'description': 'The email address of the user streaming.'},
{'name': 'User Thumb', 'type': 'str', 'value': 'user_thumb', 'description': 'The profile picture URL of the user streaming.'},
{'name': 'Device', 'type': 'str', 'value': 'device', 'description': 'The type of client device being used for playback.'},
{'name': 'Platform', 'type': 'str', 'value': 'platform', 'description': 'The type of client platform being used for playback.'},
{'name': 'Product', 'type': 'str', 'value': 'product', 'description': 'The type of client product being used for playback.'},
{'name': 'Player', 'type': 'str', 'value': 'player', 'description': 'The name of the player being used for playback.'},
{'name': 'Initial Stream', 'type': 'int', 'value': 'initial_stream', 'description': 'If the stream is the initial stream of a continuous streaming session.', 'example': '0 or 1'},
{'name': 'IP Address', 'type': 'str', 'value': 'ip_address', 'description': 'The IP address of the device being used for playback.'},
{'name': 'Stream Duration', 'type': 'int', 'value': 'stream_duration', 'description': 'The duration (in minutes) for the stream.'},
{'name': 'Stream Time', 'type': 'str', 'value': 'stream_time', 'description': 'The duration (in time format) of the stream.'},
@ -389,7 +393,7 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Relayed', 'type': 'int', 'value': 'relayed', 'description': 'If the stream is using Plex Relay.', 'example': '0 or 1'},
{'name': 'Stream Local', 'type': 'int', 'value': 'stream_local', 'description': 'If the stream is local.', 'example': '0 or 1'},
{'name': 'Stream Location', 'type': 'str', 'value': 'stream_location', 'description': 'The network location of the stream.', 'example': 'lan or wan'},
{'name': 'Stream Bandwidth', 'type': 'int', 'value': 'stream_bandwidth', 'description': 'The required bandwidth (in kbps) of the stream.', 'help_text': 'not the used bandwidth'},
{'name': 'Stream Bandwidth', 'type': 'int', 'value': 'stream_bandwidth', 'description': 'The Plex Streaming Brain reserved bandwidth (in kbps) of the stream.', 'help_text': 'not the used bandwidth'},
{'name': 'Stream Container', 'type': 'str', 'value': 'stream_container', 'description': 'The media container of the stream.'},
{'name': 'Stream Bitrate', 'type': 'int', 'value': 'stream_bitrate', 'description': 'The bitrate (in kbps) of the stream.'},
{'name': 'Stream Aspect Ratio', 'type': 'float', 'value': 'stream_aspect_ratio', 'description': 'The aspect ratio of the stream.'},
@ -556,6 +560,18 @@ NOTIFICATION_PARAMETERS = [
{'name': 'Indexes', 'type': 'int', 'value': 'indexes', 'description': 'If the media has video preview thumbnails.', 'example': '0 or 1'},
]
},
{
'category': 'Plex Remote Access',
'parameters': [
{'name': 'Remote Access Mapping State', 'type': 'str', 'value': 'remote_access_mapping_state', 'description': 'The mapping state of the Plex remote access port.'},
{'name': 'Remote Access Mapping Error', 'type': 'str', 'value': 'remote_access_mapping_error', 'description': 'The mapping error of the Plex remote access port.'},
{'name': 'Remote Access Public IP Address', 'type': 'str', 'value': 'remote_access_public_address', 'description': 'The Plex remote access public IP address.'},
{'name': 'Remote Access Public Port', 'type': 'str', 'value': 'remote_access_public_port', 'description': 'The Plex remote access public port.'},
{'name': 'Remote Access Private IP Address', 'type': 'str', 'value': 'remote_access_private_address', 'description': 'The Plex remote access private IP address.'},
{'name': 'Remote Access Private Port', 'type': 'str', 'value': 'remote_access_private_port', 'description': 'The Plex remote access private port.'},
{'name': 'Remote Access Failure Reason', 'type': 'str', 'value': 'remote_access_reason', 'description': 'The failure reason for Plex remote access going down.'},
]
},
{
'category': 'Plex Update Available',
'parameters': [

View file

@ -182,9 +182,6 @@ _CONFIG_DEFINITIONS = {
'FACEBOOK_ON_NEWDEVICE': (int, 'Facebook', 0),
'FIRST_RUN_COMPLETE': (int, 'General', 0),
'FREEZE_DB': (int, 'General', 0),
'GEOIP_DB': (str, 'General', ''),
'GEOIP_DB_INSTALLED': (int, 'General', 0),
'GEOIP_DB_UPDATE_DAYS': (int, 'General', 30),
'GET_FILE_SIZES': (int, 'General', 0),
'GET_FILE_SIZES_HOLD': (dict, 'General', {'section_ids': [], 'rating_keys': []}),
'GIT_BRANCH': (str, 'General', 'master'),
@ -299,7 +296,6 @@ _CONFIG_DEFINITIONS = {
'LOG_BLACKLIST': (int, 'General', 1),
'LOG_DIR': (str, 'General', ''),
'LOGGING_IGNORE_INTERVAL': (int, 'Monitoring', 120),
'MAXMIND_LICENSE_KEY': (str, 'General', ''),
'METADATA_CACHE_SECONDS': (int, 'Advanced', 1800),
'MOVIE_LOGGING_ENABLE': (int, 'Monitoring', 1),
'MOVIE_NOTIFY_ENABLE': (int, 'Monitoring', 0),
@ -345,6 +341,7 @@ _CONFIG_DEFINITIONS = {
'NMA_ON_NEWDEVICE': (int, 'NMA', 0),
'NOTIFICATION_THREADS': (int, 'Advanced', 2),
'NOTIFY_CONSECUTIVE': (int, 'Monitoring', 1),
'NOTIFY_CONTINUED_SESSION_THRESHOLD': (int, 'Monitoring', 15),
'NOTIFY_GROUP_RECENTLY_ADDED_GRANDPARENT': (int, 'Monitoring', 1),
'NOTIFY_GROUP_RECENTLY_ADDED_PARENT': (int, 'Monitoring', 1),
'NOTIFY_GROUP_RECENTLY_ADDED': (int, 'Monitoring', 1),
@ -497,6 +494,7 @@ _CONFIG_DEFINITIONS = {
'REFRESH_LIBRARIES_ON_STARTUP': (int, 'Monitoring', 1),
'REFRESH_USERS_INTERVAL': (int, 'Monitoring', 12),
'REFRESH_USERS_ON_STARTUP': (int, 'Monitoring', 1),
'REMOTE_ACCESS_PING_INTERVAL': (int, 'Advanced', 60),
'REMOTE_ACCESS_PING_THRESHOLD': (int, 'Advanced', 3),
'SESSION_DB_WRITE_ATTEMPTS': (int, 'Advanced', 5),
'SHOW_ADVANCED_SETTINGS': (int, 'General', 0),
@ -937,8 +935,6 @@ class Config(object):
self.CONFIG_VERSION = 13
if self.CONFIG_VERSION == 13:
if not self.GEOIP_DB:
self.GEOIP_DB = os.path.join(plexpy.DATA_DIR, 'GeoLite2-City.mmdb')
self.CONFIG_VERSION = 14

View file

@ -250,7 +250,7 @@ class MonitorDatabase(object):
sql_results = self.action(query, args).fetchone()
if sql_results is None or sql_results == "":
return ""
return {}
return sql_results

View file

@ -246,6 +246,7 @@ class DataFactory(object):
row = {'reference_id': item['reference_id'],
'row_id': item['row_id'],
'id': item['row_id'],
'date': item['date'],
'started': item['started'],
'stopped': item['stopped'],

View file

@ -23,15 +23,12 @@ from future.builtins import str
import arrow
import base64
import certifi
import cloudinary
from cloudinary.api import delete_resources_by_tag
from cloudinary.uploader import upload
from cloudinary.utils import cloudinary_url
import datetime
from functools import wraps
import geoip2.database
import geoip2.errors
import hashlib
import imghdr
from future.moves.itertools import zip_longest
@ -41,19 +38,14 @@ import ipwhois.utils
from IPy import IP
import json
import math
import maxminddb
from operator import itemgetter
import os
import re
import shlex
import shutil
import socket
import sys
import tarfile
import time
import unicodedata
from future.moves.urllib.parse import urlencode
import urllib3
from xml.dom import minidom
import xmltodict
@ -612,164 +604,6 @@ def is_valid_ip(address):
return False
def update_geoip_db():
if plexpy.CONFIG.GEOIP_DB_INSTALLED:
logger.info("Tautulli Helpers :: Checking for GeoLite2 database updates.")
now = timestamp()
if now - plexpy.CONFIG.GEOIP_DB_INSTALLED >= plexpy.CONFIG.GEOIP_DB_UPDATE_DAYS * 24 * 60 * 60:
return install_geoip_db(update=True)
logger.info("Tautulli Helpers :: GeoLite2 database already updated within the last %s days."
% plexpy.CONFIG.GEOIP_DB_UPDATE_DAYS)
def install_geoip_db(update=False):
if not plexpy.CONFIG.MAXMIND_LICENSE_KEY:
logger.error("Tautulli Helpers :: Failed to download GeoLite2 database file from MaxMind: Missing MaxMindLicense Key")
return False
maxmind_db = 'GeoLite2-City'
maxmind_url = 'https://download.maxmind.com/app/geoip_download?edition_id={db}&suffix={{suffix}}&license_key={key}'.format(
db=maxmind_db, key=plexpy.CONFIG.MAXMIND_LICENSE_KEY)
geolite2_db_url = maxmind_url.format(suffix='tar.gz')
geolite2_md5_url = maxmind_url.format(suffix='tar.gz.md5')
geolite2_gz = maxmind_db + '.tar.gz'
geolite2_md5 = geolite2_gz + '.md5'
geolite2_db = maxmind_db + '.mmdb'
geolite2_db_path = plexpy.CONFIG.GEOIP_DB or os.path.join(plexpy.DATA_DIR, geolite2_db)
# Check path ends with .mmdb
if os.path.splitext(geolite2_db_path)[1] != os.path.splitext(geolite2_db)[1]:
geolite2_db_path = os.path.join(geolite2_db_path, geolite2_db)
temp_gz = os.path.join(plexpy.CONFIG.CACHE_DIR, geolite2_gz)
temp_md5 = os.path.join(plexpy.CONFIG.CACHE_DIR, geolite2_md5)
# Retrieve the GeoLite2 gzip file
logger.debug("Tautulli Helpers :: Downloading GeoLite2 gzip file from MaxMind...")
try:
maxmind = urllib3.PoolManager(cert_reqs='CERT_REQUIRED', ca_certs=certifi.where())
with maxmind.request('GET', geolite2_db_url, preload_content=False) as r_db, open(temp_gz, 'wb') as f_db:
shutil.copyfileobj(r_db, f_db)
with maxmind.request('GET', geolite2_md5_url, preload_content=False) as r_md5, open(temp_md5, 'wb') as f_md5:
shutil.copyfileobj(r_md5, f_md5)
except Exception as e:
logger.error("Tautulli Helpers :: Failed to download GeoLite2 gzip file from MaxMind: %s" % e)
return False
# Check MD5 hash for GeoLite2 tar.gz file
logger.debug("Tautulli Helpers :: Checking MD5 checksum for GeoLite2 gzip file...")
try:
hash_md5 = hashlib.md5()
with open(temp_gz, 'rb') as f:
for chunk in iter(lambda: f.read(4096), b""):
hash_md5.update(chunk)
md5_hash = hash_md5.hexdigest()
with open(temp_md5, 'r') as f:
md5_checksum = f.read()
if md5_hash != md5_checksum:
logger.error("Tautulli Helpers :: MD5 checksum doesn't match for GeoLite2 database. "
"Checksum: %s, file hash: %s" % (md5_checksum, md5_hash))
return False
except Exception as e:
logger.error("Tautulli Helpers :: Failed to generate MD5 checksum for GeoLite2 gzip file: %s" % e)
return False
# Extract the GeoLite2 database file
logger.debug("Tautulli Helpers :: Extracting GeoLite2 database...")
try:
mmdb = None
with tarfile.open(temp_gz, 'r:gz') as tar:
for member in tar.getmembers():
if geolite2_db in member.name:
member.name = os.path.basename(member.name)
tar.extractall(path=os.path.dirname(geolite2_db_path), members=[member])
mmdb = True
break
if not mmdb:
raise Exception("{} not found in gzip file.".format(geolite2_db))
except Exception as e:
logger.error("Tautulli Helpers :: Failed to extract the GeoLite2 database: %s" % e)
return False
# Delete temportary GeoLite2 gzip file
logger.debug("Tautulli Helpers :: Deleting temporary GeoLite2 gzip file...")
try:
os.remove(temp_gz)
os.remove(temp_md5)
except Exception as e:
logger.warn("Tautulli Helpers :: Failed to remove temporary GeoLite2 gzip file: %s" % e)
plexpy.CONFIG.__setattr__('GEOIP_DB', geolite2_db_path)
plexpy.CONFIG.__setattr__('GEOIP_DB_INSTALLED', timestamp())
plexpy.CONFIG.write()
logger.debug("Tautulli Helpers :: GeoLite2 database installed successfully.")
if not update:
plexpy.schedule_job(update_geoip_db, 'Update GeoLite2 database', hours=12, minutes=0, seconds=0)
return plexpy.CONFIG.GEOIP_DB_INSTALLED
def uninstall_geoip_db():
logger.debug("Tautulli Helpers :: Uninstalling the GeoLite2 database...")
try:
os.remove(plexpy.CONFIG.GEOIP_DB)
except Exception as e:
logger.error("Tautulli Helpers :: Failed to uninstall the GeoLite2 database: %s" % e)
return False
plexpy.CONFIG.__setattr__('GEOIP_DB_INSTALLED', 0)
plexpy.CONFIG.write()
logger.debug("Tautulli Helpers :: GeoLite2 database uninstalled successfully.")
plexpy.schedule_job(update_geoip_db, 'Update GeoLite2 database', hours=0, minutes=0, seconds=0)
return True
def geoip_lookup(ip_address):
if not plexpy.CONFIG.GEOIP_DB_INSTALLED:
return 'GeoLite2 database not installed. Please install from the ' \
'<a href="settings?install_geoip=true">Settings</a> page.'
if not ip_address:
return 'No IP address provided.'
try:
reader = geoip2.database.Reader(plexpy.CONFIG.GEOIP_DB)
geo = reader.city(ip_address)
reader.close()
except ValueError as e:
return 'Invalid IP address provided: %s.' % ip_address
except IOError as e:
return 'Missing GeoLite2 database. Please reinstall from the ' \
'<a href="settings?install_geoip=true">Settings</a> page.'
except maxminddb.InvalidDatabaseError as e:
return 'Invalid GeoLite2 database. Please reinstall from the ' \
'<a href="settings?install_geoip=true">Settings</a> page.'
except geoip2.errors.AddressNotFoundError as e:
return '%s' % e
except Exception as e:
return 'Error: %s' % e
geo_info = {'continent': geo.continent.name,
'country': geo.country.name,
'region': geo.subdivisions.most_specific.name,
'city': geo.city.name,
'postal_code': geo.postal.code,
'timezone': geo.location.time_zone,
'latitude': geo.location.latitude,
'longitude': geo.location.longitude,
'accuracy': geo.location.accuracy_radius
}
return geo_info
def whois_lookup(ip_address):
nets = []

View file

@ -755,7 +755,7 @@ class Libraries(object):
except Exception as e:
logger.warn("Tautulli Libraries :: Unable to execute database query for set_config: %s." % e)
def get_details(self, section_id=None):
def get_details(self, section_id=None, server_id=None):
default_return = {'row_id': 0,
'server_id': '',
'section_id': 0,
@ -776,7 +776,10 @@ class Libraries(object):
if not section_id:
return default_return
def get_library_details(section_id=section_id):
if server_id is None:
server_id = plexpy.CONFIG.PMS_IDENTIFIER
def get_library_details(section_id=section_id, server_id=server_id):
monitor_db = database.MonitorDatabase()
try:
@ -787,8 +790,8 @@ class Libraries(object):
'custom_art_url AS custom_art, is_active, ' \
'do_notify, do_notify_created, keep_history, deleted_section ' \
'FROM library_sections ' \
'WHERE section_id = ? '
result = monitor_db.select(query, args=[section_id])
'WHERE section_id = ? AND server_id = ? '
result = monitor_db.select(query, args=[section_id, server_id])
else:
result = []
except Exception as e:
@ -828,7 +831,7 @@ class Libraries(object):
}
return library_details
library_details = get_library_details(section_id=section_id)
library_details = get_library_details(section_id=section_id, server_id=server_id)
if library_details:
return library_details
@ -839,7 +842,7 @@ class Libraries(object):
# Let's first refresh the libraries list to make sure the library isn't newly added and not in the db yet
refresh_libraries()
library_details = get_library_details(section_id=section_id)
library_details = get_library_details(section_id=section_id, server_id=server_id)
if library_details:
return library_details

View file

@ -18,7 +18,6 @@
from __future__ import unicode_literals
import os
import time
from apscheduler.triggers.cron import CronTrigger
import email.utils

View file

@ -565,6 +565,10 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
stream_count = len(sessions)
user_stream_count = len(user_sessions)
lan_bandwidth = sum(helpers.cast_to_int(s['bandwidth']) for s in sessions if s['location'] == 'lan')
wan_bandwidth = sum(helpers.cast_to_int(s['bandwidth']) for s in sessions if s['location'] != 'lan')
total_bandwidth = lan_bandwidth + wan_bandwidth
# Generate a combined transcode decision value
if session.get('stream_video_decision', '') == 'transcode' or session.get('stream_audio_decision', '') == 'transcode':
transcode_decision = 'Transcode'
@ -650,6 +654,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
themoviedb_info = lookup_themoviedb_by_id(rating_key=lookup_key,
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
themoviedb_info.pop('rating_key', None)
notify_params.update(themoviedb_info)
# Get TVmaze info (for tv shows only)
@ -665,6 +670,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
tvmaze_info = lookup_tvmaze_by_id(rating_key=lookup_key,
thetvdb_id=notify_params.get('thetvdb_id'),
imdb_id=notify_params.get('imdb_id'))
tvmaze_info.pop('rating_key', None)
notify_params.update(tvmaze_info)
if tvmaze_info.get('thetvdb_id'):
@ -685,7 +691,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
tracks = notify_params['children_count']
else:
musicbrainz_type = 'recording'
artist = notify_params['original_title']
artist = notify_params['original_title'] or notify_params['grandparent_title']
release = notify_params['parent_title']
recording = notify_params['title']
tracks = notify_params['children_count']
@ -694,6 +700,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
musicbrainz_info = lookup_musicbrainz_info(musicbrainz_type=musicbrainz_type, rating_key=rating_key,
artist=artist, release=release, recording=recording, tracks=tracks,
tnum=tnum)
musicbrainz_info.pop('rating_key', None)
notify_params.update(musicbrainz_info)
if notify_params['media_type'] in ('movie', 'show', 'artist'):
@ -831,6 +838,9 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'direct_plays': transcode_decision_count['direct play'],
'direct_streams': transcode_decision_count['copy'],
'transcodes': transcode_decision_count['transcode'],
'total_bandwidth': total_bandwidth,
'lan_bandwidth': lan_bandwidth,
'wan_bandwidth': wan_bandwidth,
'user_streams': user_stream_count,
'user_direct_plays': user_transcode_decision_count['direct play'],
'user_direct_streams': user_transcode_decision_count['copy'],
@ -838,6 +848,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'user': notify_params['friendly_name'],
'username': notify_params['user'],
'user_email': notify_params['email'],
'user_thumb': notify_params['user_thumb'],
'device': notify_params['device'],
'platform': notify_params['platform'],
'product': notify_params['product'],
@ -850,6 +861,7 @@ def build_media_notify_params(notify_action=None, session=None, timeline=None, m
'progress_duration': view_offset,
'progress_time': arrow.get(view_offset * 60).format(duration_format),
'progress_percent': helpers.get_percent(view_offset, duration),
'initial_stream': notify_params['initial_stream'],
'transcode_decision': transcode_decision,
'video_decision': notify_params['video_decision'],
'audio_decision': notify_params['audio_decision'],
@ -1047,6 +1059,7 @@ def build_server_notify_params(notify_action=None, **kwargs):
pms_download_info = defaultdict(str, kwargs.pop('pms_download_info', {}))
plexpy_download_info = defaultdict(str, kwargs.pop('plexpy_download_info', {}))
remote_access_info = defaultdict(str, kwargs.pop('remote_access_info', {}))
now = arrow.now()
now_iso = now.isocalendar()
@ -1078,6 +1091,14 @@ def build_server_notify_params(notify_action=None, **kwargs):
'timestamp': now.format(time_format),
'unixtime': helpers.timestamp(),
'utctime': helpers.utc_now_iso(),
# Plex remote access parameters
'remote_access_mapping_state': remote_access_info['mapping_state'],
'remote_access_mapping_error': remote_access_info['mapping_error'],
'remote_access_public_address': remote_access_info['public_address'],
'remote_access_public_port': remote_access_info['public_port'],
'remote_access_private_address': remote_access_info['private_address'],
'remote_access_private_port': remote_access_info['private_port'],
'remote_access_reason': remote_access_info['reason'],
# Plex Media Server update parameters
'update_version': pms_download_info['version'],
'update_url': pms_download_info['download_url'],

View file

@ -16,7 +16,6 @@
# along with Tautulli. If not, see <http://www.gnu.org/licenses/>.
from __future__ import unicode_literals
from future.builtins import next
from future.builtins import str
from future.builtins import object
@ -81,7 +80,6 @@ else:
BROWSER_NOTIFIERS = {}
AGENT_IDS = {'growl': 0,
'prowl': 1,
'xbmc': 2,
@ -104,7 +102,8 @@ AGENT_IDS = {'growl': 0,
'groupme': 22,
'mqtt': 23,
'zapier': 24,
'webhook': 25
'webhook': 25,
'plexmobileapp': 26
}
DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
@ -113,91 +112,141 @@ DEFAULT_CUSTOM_CONDITIONS = [{'parameter': '', 'operator': '', 'value': ''}]
def available_notification_agents():
agents = [{'label': 'Tautulli Remote Android App',
'name': 'androidapp',
'id': AGENT_IDS['androidapp']
'id': AGENT_IDS['androidapp'],
'class': ANDROIDAPP,
'action_types': ('all',)
},
{'label': 'Boxcar',
'name': 'boxcar',
'id': AGENT_IDS['boxcar']
'id': AGENT_IDS['boxcar'],
'class': BOXCAR,
'action_types': ('all',)
},
{'label': 'Browser',
'name': 'browser',
'id': AGENT_IDS['browser']
'id': AGENT_IDS['browser'],
'class': BROWSER,
'action_types': ('all',)
},
{'label': 'Discord',
'name': 'discord',
'id': AGENT_IDS['discord'],
'class': DISCORD,
'action_types': ('all',)
},
{'label': 'Email',
'name': 'email',
'id': AGENT_IDS['email']
'id': AGENT_IDS['email'],
'class': EMAIL,
'action_types': ('all',)
},
{'label': 'Facebook',
'name': 'facebook',
'id': AGENT_IDS['facebook']
'id': AGENT_IDS['facebook'],
'class': FACEBOOK,
'action_types': ('all',)
},
{'label': 'GroupMe',
'name': 'groupme',
'id': AGENT_IDS['groupme']
'id': AGENT_IDS['groupme'],
'class': GROUPME,
'action_types': ('all',)
},
{'label': 'Growl',
'name': 'growl',
'id': AGENT_IDS['growl']
'id': AGENT_IDS['growl'],
'class': GROWL,
'action_types': ('all',)
},
{'label': 'IFTTT',
'name': 'ifttt',
'id': AGENT_IDS['ifttt']
'id': AGENT_IDS['ifttt'],
'class': IFTTT,
'action_types': ('all',)
},
{'label': 'Join',
'name': 'join',
'id': AGENT_IDS['join']
'id': AGENT_IDS['join'],
'class': JOIN,
'action_types': ('all',)
},
{'label': 'Kodi',
'name': 'xbmc',
'id': AGENT_IDS['xbmc']
'id': AGENT_IDS['xbmc'],
'class': XBMC,
'action_types': ('all',)
},
{'label': 'MQTT',
'name': 'mqtt',
'id': AGENT_IDS['mqtt']
'id': AGENT_IDS['mqtt'],
'class': MQTT,
'action_types': ('all',)
},
{'label': 'Plex Home Theater',
'name': 'plex',
'id': AGENT_IDS['plex']
'id': AGENT_IDS['plex'],
'class': PLEX,
'action_types': ('all',)
},
{'label': 'Plex Android / iOS App',
'name': 'plexmobileapp',
'id': AGENT_IDS['plexmobileapp'],
'class': PLEXMOBILEAPP,
'action_types': ('on_play', 'on_created', 'on_newdevice')
},
{'label': 'Prowl',
'name': 'prowl',
'id': AGENT_IDS['prowl']
'id': AGENT_IDS['prowl'],
'class': PROWL,
'action_types': ('all',)
},
{'label': 'Pushbullet',
'name': 'pushbullet',
'id': AGENT_IDS['pushbullet']
'id': AGENT_IDS['pushbullet'],
'class': PUSHBULLET,
'action_types': ('all',)
},
{'label': 'Pushover',
'name': 'pushover',
'id': AGENT_IDS['pushover']
'id': AGENT_IDS['pushover'],
'class': PUSHOVER,
'action_types': ('all',)
},
{'label': 'Script',
'name': 'scripts',
'id': AGENT_IDS['scripts']
'id': AGENT_IDS['scripts'],
'class': SCRIPTS,
'action_types': ('all',)
},
{'label': 'Slack',
'name': 'slack',
'id': AGENT_IDS['slack']
'id': AGENT_IDS['slack'],
'class': SLACK,
'action_types': ('all',)
},
{'label': 'Telegram',
'name': 'telegram',
'id': AGENT_IDS['telegram']
'id': AGENT_IDS['telegram'],
'class': TELEGRAM,
'action_types': ('all',)
},
{'label': 'Twitter',
'name': 'twitter',
'id': AGENT_IDS['twitter']
'id': AGENT_IDS['twitter'],
'class': TWITTER,
'action_types': ('all',)
},
{'label': 'Webhook',
'name': 'webhook',
'id': AGENT_IDS['webhook']
'id': AGENT_IDS['webhook'],
'class': WEBHOOK,
'action_types': ('all',)
},
{'label': 'Zapier',
'name': 'zapier',
'id': AGENT_IDS['zapier']
'id': AGENT_IDS['zapier'],
'class': ZAPIER,
'action_types': ('all',)
}
]
@ -205,13 +254,15 @@ def available_notification_agents():
if OSX().validate():
agents.append({'label': 'macOS Notification Center',
'name': 'osx',
'id': AGENT_IDS['osx']
'id': AGENT_IDS['osx'],
'class': OSX,
'action_types': ('all',)
})
return agents
def available_notification_actions():
def available_notification_actions(agent_id=None):
actions = [{'label': 'Playback Start',
'name': 'on_play',
'description': 'Trigger a notification when a stream is started.',
@ -312,7 +363,7 @@ def available_notification_actions():
'name': 'on_extdown',
'description': 'Trigger a notification when the Plex Media Server cannot be reached externally.',
'subject': 'Tautulli ({server_name})',
'body': 'The Plex Media Server remote access is down.',
'body': 'The Plex Media Server remote access is down. ({remote_access_reason})',
'icon': 'fa-server',
'media_types': ('server',)
},
@ -350,72 +401,31 @@ def available_notification_actions():
}
]
if str(agent_id).isdigit():
action_types = get_notify_agents(return_dict=True).get(int(agent_id), {}).get('action_types', [])
if 'all' not in action_types:
actions = [a for a in actions if a['name'] in action_types]
return actions
def get_agent_class(agent_id=None, config=None):
if str(agent_id).isdigit():
agent_id = int(agent_id)
if agent_id == 0:
return GROWL(config=config)
elif agent_id == 1:
return PROWL(config=config)
elif agent_id == 2:
return XBMC(config=config)
elif agent_id == 3:
return PLEX(config=config)
elif agent_id == 6:
return PUSHBULLET(config=config)
elif agent_id == 7:
return PUSHOVER(config=config)
elif agent_id == 8:
return OSX(config=config)
elif agent_id == 9:
return BOXCAR(config=config)
elif agent_id == 10:
return EMAIL(config=config)
elif agent_id == 11:
return TWITTER(config=config)
elif agent_id == 12:
return IFTTT(config=config)
elif agent_id == 13:
return TELEGRAM(config=config)
elif agent_id == 14:
return SLACK(config=config)
elif agent_id == 15:
return SCRIPTS(config=config)
elif agent_id == 16:
return FACEBOOK(config=config)
elif agent_id == 17:
return BROWSER(config=config)
elif agent_id == 18:
return JOIN(config=config)
elif agent_id == 20:
return DISCORD(config=config)
elif agent_id == 21:
return ANDROIDAPP(config=config)
elif agent_id == 22:
return GROUPME(config=config)
elif agent_id == 23:
return MQTT(config=config)
elif agent_id == 24:
return ZAPIER(config=config)
elif agent_id == 25:
return WEBHOOK(config=config)
else:
return Notifier(config=config)
agent = get_notify_agents(return_dict=True).get(int(agent_id), {}).get('class', Notifier)
return agent(config=config)
else:
return None
def get_notify_agents():
def get_notify_agents(return_dict=False):
if return_dict:
return {a['id']: a for a in available_notification_agents()}
return tuple(a['name'] for a in sorted(available_notification_agents(), key=lambda k: k['label']))
def get_notify_actions(return_dict=False):
if return_dict:
return {a.pop('name'): a for a in available_notification_actions()}
return {a['name']: a for a in available_notification_actions()}
return tuple(a['name'] for a in available_notification_actions())
@ -523,7 +533,7 @@ def add_notifier_config(agent_id=None, **kwargs):
% agent_id)
return False
agent = next((a for a in available_notification_agents() if a['id'] == agent_id), None)
agent = get_notify_agents(return_dict=True).get(agent_id, None)
if not agent:
logger.error("Tautulli Notifiers :: Unable to retrieve new notification agent: invalid agent_id %s."
@ -572,7 +582,7 @@ def set_notifier_config(notifier_id=None, agent_id=None, **kwargs):
% agent_id)
return False
agent = next((a for a in available_notification_agents() if a['id'] == agent_id), None)
agent = get_notify_agents(return_dict=True).get(agent_id, None)
if not agent:
logger.error("Tautulli Notifiers :: Unable to retrieve existing notification agent: invalid agent_id %s."
@ -2368,6 +2378,190 @@ class PLEX(Notifier):
return config_option
class PLEXMOBILEAPP(Notifier):
"""
Plex Mobile App Notifications
"""
NAME = 'Plex Android / iOS App'
NOTIFICATION_URL = 'https://notifications.plex.tv/api/v1/notifications'
_DEFAULT_CONFIG = {'user_ids': [],
'tap_action': 'preplay',
}
def __init__(self, config=None):
super(PLEXMOBILEAPP, self).__init__(config=config)
self.configurations = {
'created': {'group': 'media', 'identifier': 'tv.plex.notification.library.new'},
'play': {'group': 'media', 'identifier': 'tv.plex.notification.playback.started'},
'newdevice': {'group': 'admin', 'identifier': 'tv.plex.notification.device.new'}
}
def agent_notify(self, subject='', body='', action='', **kwargs):
if action not in self.configurations and not action.startswith('test'):
logger.error(u"Tautulli Notifiers :: Notification action %s not allowed for %s." % (action, self.NAME))
return
if action == 'test':
tests = []
for configuration in self.configurations:
tests.append(self.agent_notify(subject=subject, body=body, action='test_'+configuration))
return all(tests)
configuration_action = action.split('test_')[-1]
# No subject to always show up regardless of client selected filters
# icon can be info, warning, or error
# play = true to start playing when tapping the notification
# Send the minimal amount of data necessary through Plex servers
data = {
'group': self.configurations[configuration_action]['group'],
'identifier': self.configurations[configuration_action]['identifier'],
'to': self.config['user_ids'],
'data': {
'provider': {
'identifier': plexpy.CONFIG.PMS_IDENTIFIER,
'title': plexpy.CONFIG.PMS_NAME
}
}
}
pretty_metadata = PrettyMetadata(kwargs.get('parameters'))
if action.startswith('test'):
data['data']['player'] = {
'title': 'Device',
'platform': 'Platform',
'machineIdentifier': 'Tautulli'
}
data['data']['user'] = {
'title': 'User',
'id': 0
}
data['metadata'] = {
'type': 'movie',
'title': subject,
'year': body
}
elif action in ('play', 'newdevice'):
data['data']['player'] = {
'title': pretty_metadata.parameters['player'],
'platform': pretty_metadata.parameters['platform'],
'machineIdentifier': pretty_metadata.parameters['machine_id']
}
data['data']['user'] = {
'title': pretty_metadata.parameters['user'],
'id': pretty_metadata.parameters['user_id'],
'thumb': pretty_metadata.parameters['user_thumb'],
}
elif action == 'created':
# No addition data required for recently added
pass
else:
logger.error(u"Tautulli Notifiers :: Notification action %s not supported for %s." % (action, self.NAME))
return
if data['group'] == 'media' and not action.startswith('test'):
media_type = pretty_metadata.media_type
uri_rating_key = None
if media_type == 'movie':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['title'],
'year': pretty_metadata.parameters['year'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'show':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['show_name'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'season':
metadata = {
'type': 'show',
'title': pretty_metadata.parameters['show_name'],
'thumb': pretty_metadata.parameters['thumb'],
}
data['data']['count'] = pretty_metadata.parameters['episode_count']
elif media_type == 'episode':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['episode_name'],
'grandparentTitle': pretty_metadata.parameters['show_name'],
'index': pretty_metadata.parameters['episode_num'],
'parentIndex': pretty_metadata.parameters['season_num'],
'grandparentThumb': pretty_metadata.parameters['grandparent_thumb']
}
elif media_type == 'artist':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['thumb']
}
elif media_type == 'album':
metadata = {
'type': media_type,
'title': pretty_metadata.parameters['album_name'],
'year': pretty_metadata.parameters['year'],
'parentTitle': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['thumb'],
}
elif media_type == 'track':
metadata = {
'type': 'album',
'title': pretty_metadata.parameters['album_name'],
'year': pretty_metadata.parameters['year'],
'parentTitle': pretty_metadata.parameters['artist_name'],
'thumb': pretty_metadata.parameters['parent_thumb']
}
uri_rating_key = pretty_metadata.parameters['parent_rating_key']
else:
logger.error(u"Tautulli Notifiers :: Media type %s not supported for %s." % (media_type, self.NAME))
return
data['metadata'] = metadata
data['uri'] = 'server://{}/com.plexapp.plugins.library/library/metadata/{}'.format(
plexpy.CONFIG.PMS_IDENTIFIER, uri_rating_key or pretty_metadata.parameters['rating_key']
)
data['play'] = self.config['tap_action'] == 'play'
headers = {'X-Plex-Token': plexpy.CONFIG.PMS_TOKEN}
return self.make_request(self.NOTIFICATION_URL, headers=headers, json=data)
def get_users(self):
user_ids = {u['user_id']: u['friendly_name'] for u in users.Users().get_users() if u['user_id']}
user_ids[''] = ''
return user_ids
def _return_config_options(self):
config_option = [{'label': 'Plex User(s)',
'value': self.config['user_ids'],
'name': 'plexmobileapp_user_ids',
'description': 'Select which Plex User(s) to receive notifications.<br>'
'Note: The user(s) must have notifications enabled '
'for the matching Tautulli triggers in their Plex mobile app.',
'input_type': 'select',
'select_options': self.get_users()
},
{'label': 'Notification Tap Action',
'value': self.config['tap_action'],
'name': 'plexmobileapp_tap_action',
'description': 'Set the action when tapping on the notification.',
'input_type': 'select',
'select_options': {'preplay': 'Go to media pre-play screen',
'play': 'Start playing the media'}
},
]
return config_option
class PROWL(Notifier):
"""
Prowl notifications.

View file

@ -390,6 +390,14 @@ class PlexTV(object):
return request
def get_plextv_geoip(self, ip_address='', output_format=''):
uri = '/api/v2/geoip?ip_address=%s' % ip_address
request = self.request_handler.make_request(uri=uri,
request_type='GET',
output_format=output_format)
return request
def get_full_users_list(self):
own_account = self.get_plextv_user_details(output_format='xml')
friends_list = self.get_plextv_friends(output_format='xml')
@ -936,3 +944,35 @@ class PlexTV(object):
"user_token": helpers.get_xml_attr(a, 'authToken')
}
return account_details
def get_geoip_lookup(self, ip_address=''):
if not ip_address or not helpers.is_public_ip(ip_address):
return
geoip_data = self.get_plextv_geoip(ip_address=ip_address, output_format='xml')
try:
xml_head = geoip_data.getElementsByTagName('location')
except Exception as e:
logger.warn(u"Tautulli PlexTV :: Unable to parse XML for get_geoip_lookup: %s." % e)
return None
for a in xml_head:
coordinates = helpers.get_xml_attr(a, 'coordinates').split(',')
latitude = longitude = None
if len(coordinates) == 2:
latitude, longitude = [helpers.cast_to_float(c) for c in coordinates]
geo_info = {"code": helpers.get_xml_attr(a, 'code') or None,
"country": helpers.get_xml_attr(a, 'country') or None,
"region": helpers.get_xml_attr(a, 'subdivisions') or None,
"city": helpers.get_xml_attr(a, 'city') or None,
"postal_code": helpers.get_xml_attr(a, 'postal_code') or None,
"timezone": helpers.get_xml_attr(a, 'time_zone') or None,
"latitude": latitude,
"longitude": longitude,
"continent": None, # keep for backwards compatibility with GeoLite2
"accuracy": None # keep for backwards compatibility with GeoLite2
}
return geo_info

View file

@ -2980,10 +2980,26 @@ class PmsConnect(object):
for a in xml_head:
server_response = {'mapping_state': helpers.get_xml_attr(a, 'mappingState'),
'mapping_error': helpers.get_xml_attr(a, 'mappingError'),
'sign_in_state': helpers.get_xml_attr(a, 'signInState'),
'public_address': helpers.get_xml_attr(a, 'publicAddress'),
'public_port': helpers.get_xml_attr(a, 'publicPort')
'public_port': helpers.get_xml_attr(a, 'publicPort'),
'private_address': helpers.get_xml_attr(a, 'privateAddress'),
'private_port': helpers.get_xml_attr(a, 'privatePort')
}
if server_response['mapping_state'] == 'unknown':
server_response['reason'] = 'Plex remote access port mapping unknown'
elif server_response['mapping_state'] not in ('mapped', 'waiting'):
server_response['reason'] = 'Plex remote access port not mapped'
elif server_response['mapping_error'] == 'unreachable':
server_response['reason'] = 'Plex remote access port mapped, ' \
'but the port is unreachable from Plex.tv'
elif server_response['mapping_error'] == 'publisherror':
server_response['reason'] = 'Plex remote access port mapped, ' \
'but failed to publish the port to Plex.tv'
else:
server_response['reason'] = ''
return server_response
def get_update_staus(self):

View file

@ -21,7 +21,6 @@ from future.builtins import str
from future.builtins import object
import httpagentparser
import time
import plexpy
if plexpy.PYTHON2:

View file

@ -18,4 +18,4 @@
from __future__ import unicode_literals
PLEXPY_BRANCH = "python3"
PLEXPY_RELEASE_VERSION = "v2.2.2-beta"
PLEXPY_RELEASE_VERSION = "v2.2.3-beta"

View file

@ -147,8 +147,8 @@ def getVersion():
return current_version, 'origin', current_branch
def check_update(auto_update=False, notify=False):
check_github(auto_update=auto_update, notify=notify)
def check_update(scheduler=False, notify=False):
check_github(scheduler=scheduler, notify=notify)
if not plexpy.CURRENT_VERSION:
plexpy.UPDATE_AVAILABLE = None
@ -171,7 +171,7 @@ def check_update(auto_update=False, notify=False):
plexpy.WIN_SYS_TRAY_ICON.update(icon=icon, hover_text=hover_text)
def check_github(auto_update=False, notify=False):
def check_github(scheduler=False, notify=False):
plexpy.COMMITS_BEHIND = 0
if plexpy.CONFIG.GIT_TOKEN:
@ -248,7 +248,7 @@ def check_github(auto_update=False, notify=False):
'plexpy_update_commit': plexpy.LATEST_VERSION,
'plexpy_update_behind': plexpy.COMMITS_BEHIND})
if auto_update and not plexpy.DOCKER:
if scheduler and plexpy.CONFIG.PLEXPY_AUTO_UPDATE and not plexpy.DOCKER:
logger.info('Running automatic update.')
plexpy.shutdown(restart=True, update=True)

View file

@ -1936,6 +1936,10 @@ class WebInterface(object):
}
```
"""
# For backwards compatibility
if 'id' in kwargs:
row_id = kwargs['id']
data_factory = datafactory.DataFactory()
stream_data = data_factory.get_stream_details(row_id, session_key)
@ -2993,6 +2997,7 @@ class WebInterface(object):
"notify_recently_added_delay": plexpy.CONFIG.NOTIFY_RECENTLY_ADDED_DELAY,
"notify_concurrent_by_ip": checked(plexpy.CONFIG.NOTIFY_CONCURRENT_BY_IP),
"notify_concurrent_threshold": plexpy.CONFIG.NOTIFY_CONCURRENT_THRESHOLD,
"notify_continued_session_threshold": plexpy.CONFIG.NOTIFY_CONTINUED_SESSION_THRESHOLD,
"home_sections": json.dumps(plexpy.CONFIG.HOME_SECTIONS),
"home_stats_cards": json.dumps(plexpy.CONFIG.HOME_STATS_CARDS),
"home_library_cards": json.dumps(plexpy.CONFIG.HOME_LIBRARY_CARDS),
@ -3024,11 +3029,7 @@ class WebInterface(object):
"newsletter_password": plexpy.CONFIG.NEWSLETTER_PASSWORD,
"newsletter_inline_styles": checked(plexpy.CONFIG.NEWSLETTER_INLINE_STYLES),
"newsletter_custom_dir": plexpy.CONFIG.NEWSLETTER_CUSTOM_DIR,
"win_sys_tray": checked(plexpy.CONFIG.WIN_SYS_TRAY),
"maxmind_license_key": plexpy.CONFIG.MAXMIND_LICENSE_KEY,
"geoip_db": plexpy.CONFIG.GEOIP_DB,
"geoip_db_installed": plexpy.CONFIG.GEOIP_DB_INSTALLED,
"geoip_db_update_days": plexpy.CONFIG.GEOIP_DB_UPDATE_DAYS
"win_sys_tray": checked(plexpy.CONFIG.WIN_SYS_TRAY)
}
return serve_template(templatename="settings.html", title="Settings", config=config, kwargs=kwargs)
@ -3260,36 +3261,6 @@ class WebInterface(object):
else:
return {'result': 'error', 'message': 'Database backup failed.'}
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def install_geoip_db(self, update=False, **kwargs):
""" Downloads and installs the GeoLite2 database """
update = helpers.bool_true(update)
result = helpers.install_geoip_db(update=update)
if result:
return {'result': 'success', 'message': 'GeoLite2 database installed successful.', 'updated': result}
else:
return {'result': 'error', 'message': 'GeoLite2 database install failed.', 'updated': 0}
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@addtoapi()
def uninstall_geoip_db(self, **kwargs):
""" Uninstalls the GeoLite2 database """
result = helpers.uninstall_geoip_db()
if result:
return {'result': 'success', 'message': 'GeoLite2 database uninstalled successfully.'}
else:
return {'result': 'error', 'message': 'GeoLite2 database uninstall failed.'}
@cherrypy.expose
@cherrypy.tools.json_out()
@requireAuth(member_of("admin"))
@ -5807,7 +5778,7 @@ class WebInterface(object):
@requireAuth()
@addtoapi()
def get_geoip_lookup(self, ip_address='', **kwargs):
""" Get the geolocation info for an IP address. The GeoLite2 database must be installed.
""" Get the geolocation info for an IP address.
```
Required parameters:
@ -5818,7 +5789,7 @@ class WebInterface(object):
Returns:
json:
{"continent": "North America",
{"code": 'US",
"country": "United States",
"region": "California",
"city": "Mountain View",
@ -5828,15 +5799,24 @@ class WebInterface(object):
"longitude": -122.0838,
"accuracy": 1000
}
json:
{"error": "The address 127.0.0.1 is not in the database."
}
```
"""
geo_info = helpers.geoip_lookup(ip_address)
if isinstance(geo_info, str):
return {'error': geo_info}
return geo_info
message = ''
if not ip_address:
message = 'No IP address provided.'
elif not helpers.is_valid_ip(ip_address):
message = 'Invalid IP address provided: %s' % ip_address
elif not helpers.is_public_ip(ip_address):
message = 'Non-public IP address provided: %s' % ip_address
if message:
return {'result': 'error', 'message': message}
plex_tv = plextv.PlexTV()
geo_info = plex_tv.get_geoip_lookup(ip_address)
if geo_info:
return {'result': 'success', 'data': geo_info}
return {'result': 'error', 'message': 'Failed to lookup GeoIP info for address: %s' % ip_address}
@cherrypy.expose
@cherrypy.tools.json_out()