mirror of
https://github.com/rembo10/headphones.git
synced 2026-07-21 08:24:00 +01:00
Moved request method to helpers. Removed unused imports
This commit is contained in:
+101
-11
@@ -13,15 +13,21 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
# along with Headphones. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os, time
|
import os
|
||||||
from operator import itemgetter
|
import re
|
||||||
|
import time
|
||||||
|
import shutil
|
||||||
import datetime
|
import datetime
|
||||||
import re, shutil
|
import requests
|
||||||
|
import feedparser
|
||||||
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
|
||||||
|
|
||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
|
from headphones import logger
|
||||||
|
|
||||||
|
from operator import itemgetter
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
from beets.mediafile import MediaFile, FileTypeError, UnreadableFileError
|
||||||
|
|
||||||
# Modified from https://github.com/Verrus/beets-plugin-featInTitle
|
# Modified from https://github.com/Verrus/beets-plugin-featInTitle
|
||||||
RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
|
RE_FEATURING = re.compile(r"[fF]t\.|[fF]eaturing|[fF]eat\.|\b[wW]ith\b|&|vs\.")
|
||||||
|
|
||||||
@@ -29,7 +35,6 @@ RE_CD_ALBUM = re.compile(r"\(?((CD|disc)\s*[0-9]+)\)?", re.I)
|
|||||||
RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
|
RE_CD = re.compile(r"^(CD|dics)\s*[0-9]+$", re.I)
|
||||||
|
|
||||||
def multikeysort(items, columns):
|
def multikeysort(items, columns):
|
||||||
|
|
||||||
comparers = [ ((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns]
|
comparers = [ ((itemgetter(col[1:].strip()), -1) if col.startswith('-') else (itemgetter(col.strip()), 1)) for col in columns]
|
||||||
|
|
||||||
def comparer(left, right):
|
def comparer(left, right):
|
||||||
@@ -236,8 +241,6 @@ def expand_subfolders(f):
|
|||||||
case, normal post processing will be better.
|
case, normal post processing will be better.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from headphones import logger
|
|
||||||
|
|
||||||
# Find all folders with media files in them
|
# Find all folders with media files in them
|
||||||
media_folders = []
|
media_folders = []
|
||||||
|
|
||||||
@@ -333,8 +336,6 @@ def extract_metadata(f):
|
|||||||
artists, albums and years found in the media files.
|
artists, albums and years found in the media files.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from headphones import logger
|
|
||||||
|
|
||||||
# Walk directory and scan all media files
|
# Walk directory and scan all media files
|
||||||
results = []
|
results = []
|
||||||
count = 0
|
count = 0
|
||||||
@@ -593,3 +594,92 @@ def create_https_certificates(ssl_cert, ssl_key):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def request_response(url, method="GET", auto_raise=True, status_pass=None, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Convenient wrapper for `requests.get', which will capture the exceptions and
|
||||||
|
log them. On success, the Response object is returned. In case of a
|
||||||
|
exception, None is returned.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Request the URL
|
||||||
|
logger.debug("Requesting URL via %s method: %s", method, url)
|
||||||
|
response = requests.request(method, url, *args, **kwargs)
|
||||||
|
|
||||||
|
# If status code != OK, then raise exception, except if the status code
|
||||||
|
# is white listed.
|
||||||
|
if status_pass and auto_raise:
|
||||||
|
if response.status_code not in status_pass:
|
||||||
|
response.raise_for_status()
|
||||||
|
else:
|
||||||
|
logger.debug("Response Status code %d is white listed, not raising exception", response.status_code)
|
||||||
|
elif auto_raise:
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
return response
|
||||||
|
except requests.ConnectionError:
|
||||||
|
logger.error("Unable to connect to remote host.")
|
||||||
|
except requests.Timeout:
|
||||||
|
logger.error("Request timed out.")
|
||||||
|
except requests.HTTPError, e:
|
||||||
|
if e.response is not None:
|
||||||
|
logger.error("Request raise HTTP error with status code: %d", e.response.status_code)
|
||||||
|
else:
|
||||||
|
logger.error("Request raised HTTP error.")
|
||||||
|
except requests.RequestException, e:
|
||||||
|
logger.error("Request raised exception: %s", e)
|
||||||
|
|
||||||
|
def request_soup(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrapper for `request_response', which will return a BeatifulSoup object if
|
||||||
|
no exceptions are raised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = request_response(*args, **kwargs)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
return BeautifulSoup(response.content)
|
||||||
|
|
||||||
|
def request_json(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrapper for `request_response', which will decode the response as JSON
|
||||||
|
object and return the result, if no exceptions are raised.
|
||||||
|
|
||||||
|
As an option, a validator callback can be given, which should return True if
|
||||||
|
the result is valid.
|
||||||
|
"""
|
||||||
|
|
||||||
|
validator = kwargs.pop("validator")
|
||||||
|
response = request_response(*args, **kwargs)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
try:
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
if validator and not validator(result):
|
||||||
|
logger.error("JSON validation result vailed")
|
||||||
|
else:
|
||||||
|
return result
|
||||||
|
except ValueError:
|
||||||
|
logger.error("Response returned invalid JSON data")
|
||||||
|
|
||||||
|
def request_content(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrapper for `request_response', which will return the raw content.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = request_response(*args, **kwargs)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
def request_feed(*args, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrapper for `request_response', which will return a feed object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = request_response(*args, **kwargs)
|
||||||
|
|
||||||
|
if response is not None:
|
||||||
|
return feedparser.parse(response.content)
|
||||||
@@ -21,7 +21,6 @@ import threading
|
|||||||
import headphones
|
import headphones
|
||||||
|
|
||||||
from logging import handlers
|
from logging import handlers
|
||||||
from headphones import helpers
|
|
||||||
|
|
||||||
# These settings are for file logging only
|
# These settings are for file logging only
|
||||||
FILENAME = 'headphones.log'
|
FILENAME = 'headphones.log'
|
||||||
@@ -40,7 +39,7 @@ class LogListHandler(logging.Handler):
|
|||||||
message = self.format(record)
|
message = self.format(record)
|
||||||
message = message.replace("\n", "<br />")
|
message = message.replace("\n", "<br />")
|
||||||
|
|
||||||
headphones.LOG_LIST.insert(0, (helpers.now(), message, record.levelname, record.threadName))
|
headphones.LOG_LIST.insert(0, (record.created, message, record.levelname, record.threadName))
|
||||||
|
|
||||||
def initLogger(verbose=1):
|
def initLogger(verbose=1):
|
||||||
"""
|
"""
|
||||||
|
|||||||
+17
-112
@@ -16,24 +16,18 @@
|
|||||||
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
|
# NZBGet support added by CurlyMo <curlymoo1@gmail.com> as a part of XBian - XBMC on the Raspberry Pi
|
||||||
|
|
||||||
import urllib, urlparse
|
import urllib, urlparse
|
||||||
import lib.feedparser as feedparser
|
|
||||||
from bs4 import BeautifulSoup
|
|
||||||
from lib.pygazelle import api as gazelleapi
|
from lib.pygazelle import api as gazelleapi
|
||||||
from lib.pygazelle import encoding as gazelleencoding
|
from lib.pygazelle import encoding as gazelleencoding
|
||||||
from lib.pygazelle import format as gazelleformat
|
from lib.pygazelle import format as gazelleformat
|
||||||
from lib.pygazelle import media as gazellemedia
|
from lib.pygazelle import media as gazellemedia
|
||||||
from xml.dom import minidom
|
from xml.dom import minidom
|
||||||
from xml.parsers.expat import ExpatError
|
|
||||||
import lib.simplejson as json
|
|
||||||
from StringIO import StringIO
|
|
||||||
import gzip, base64
|
|
||||||
|
|
||||||
import os, re, time
|
import os, re, time
|
||||||
import string
|
import string
|
||||||
import shutil
|
import shutil
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
import headphones, exceptions
|
import headphones
|
||||||
from headphones.common import USER_AGENT
|
from headphones.common import USER_AGENT
|
||||||
from headphones import logger, db, helpers, classes, sab, nzbget
|
from headphones import logger, db, helpers, classes, sab, nzbget
|
||||||
from headphones import transmission
|
from headphones import transmission
|
||||||
@@ -46,94 +40,6 @@ rutracker = rutrackersearch.Rutracker()
|
|||||||
# Persistent What.cd API object
|
# Persistent What.cd API object
|
||||||
gazelle = None
|
gazelle = None
|
||||||
|
|
||||||
def request_response(url, method="GET", auto_raise=True, status_pass=None, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Convenient wrapper for `requests.get', which will capture the exceptions and
|
|
||||||
log them. On success, the Response object is returned. In case of a
|
|
||||||
exception, None is returned.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Request the URL
|
|
||||||
logger.debug("Requesting URL via %s method: %s", method, url)
|
|
||||||
response = requests.request(method, url, *args, **kwargs)
|
|
||||||
|
|
||||||
# If status code != OK, then raise exception, except if the status code
|
|
||||||
# is white listed.
|
|
||||||
if status_pass and auto_raise:
|
|
||||||
if response.status_code not in status_pass:
|
|
||||||
response.raise_for_status()
|
|
||||||
else:
|
|
||||||
logger.debug("Response code %d is white listed, not raising exception", response.status_code)
|
|
||||||
elif auto_raise:
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
return response
|
|
||||||
except requests.ConnectionError:
|
|
||||||
logger.error("Unable to connect to remote host.")
|
|
||||||
except requests.Timeout:
|
|
||||||
logger.error("Request timed out.")
|
|
||||||
except requests.HTTPError, e:
|
|
||||||
if e.response:
|
|
||||||
logger.error("Request returned unexpected status code: %d", e.status_code)
|
|
||||||
else:
|
|
||||||
logger.error("Request raised an HTTP error.")
|
|
||||||
except requests.RequestException, e:
|
|
||||||
logger.error("Request raised exception: %s", e)
|
|
||||||
|
|
||||||
def request_soup(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Wrapper for `request_response', which will return a BeatifulSoup object if
|
|
||||||
no exceptions are raised.
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = request_response(*args, **kwargs)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
return BeautifulSoup(response.content)
|
|
||||||
|
|
||||||
def request_json(validator=None, *args, **kwargs):
|
|
||||||
"""
|
|
||||||
Wrapper for `request_response', which will decode the response as JSON
|
|
||||||
object and return the result, if no exceptions are raised.
|
|
||||||
|
|
||||||
As an option, a validator callback can be given, which should return True if
|
|
||||||
the result is valid.
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = request_response(*args, **kwargs)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
try:
|
|
||||||
result = response.json()
|
|
||||||
|
|
||||||
if validator and not validator(result):
|
|
||||||
logger.error("JSON validation result vailed")
|
|
||||||
else:
|
|
||||||
return result
|
|
||||||
except ValueError:
|
|
||||||
logger.error("Response returned invalid JSON data")
|
|
||||||
|
|
||||||
def request_content(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Wrapper for `request_response', which will return the raw content.
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = request_response(*args, **kwargs)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
return response.content
|
|
||||||
|
|
||||||
def request_feed(*args, **kwargs):
|
|
||||||
"""
|
|
||||||
Wrapper for `request_response', which will return a feed object.
|
|
||||||
"""
|
|
||||||
|
|
||||||
response = request_response(*args, **kwargs)
|
|
||||||
|
|
||||||
if response:
|
|
||||||
return feedparser.parse(response.content)
|
|
||||||
|
|
||||||
def url_fix(s, charset='utf-8'):
|
def url_fix(s, charset='utf-8'):
|
||||||
if isinstance(s, unicode):
|
if isinstance(s, unicode):
|
||||||
s = s.encode(charset, 'ignore')
|
s = s.encode(charset, 'ignore')
|
||||||
@@ -146,7 +52,6 @@ def searchforalbum(albumid=None, new=False, losslessOnly=False, choose_specific_
|
|||||||
myDB = db.DBConnection()
|
myDB = db.DBConnection()
|
||||||
|
|
||||||
if not albumid:
|
if not albumid:
|
||||||
|
|
||||||
results = myDB.select('SELECT * from albums WHERE Status="Wanted" OR Status="Wanted Lossless"')
|
results = myDB.select('SELECT * from albums WHERE Status="Wanted" OR Status="Wanted Lossless"')
|
||||||
|
|
||||||
for album in results:
|
for album in results:
|
||||||
@@ -406,7 +311,7 @@ def searchNZB(album, new=False, losslessOnly=False):
|
|||||||
"q": term
|
"q": term
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url="http://headphones.codeshy.com/newznab/api",
|
url="http://headphones.codeshy.com/newznab/api",
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
auth=(headphones.HPUSER, headphones.HPPASS)
|
auth=(headphones.HPUSER, headphones.HPPASS)
|
||||||
@@ -472,7 +377,7 @@ def searchNZB(album, new=False, losslessOnly=False):
|
|||||||
"q": term
|
"q": term
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url=newznab_host[0] + '/api?',
|
url=newznab_host[0] + '/api?',
|
||||||
params=params, headers=headers
|
params=params, headers=headers
|
||||||
)
|
)
|
||||||
@@ -519,7 +424,7 @@ def searchNZB(album, new=False, losslessOnly=False):
|
|||||||
"q": term
|
"q": term
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url='http://beta.nzbs.org/api',
|
url='http://beta.nzbs.org/api',
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
timeout=20
|
timeout=20
|
||||||
@@ -569,7 +474,7 @@ def searchNZB(album, new=False, losslessOnly=False):
|
|||||||
"searchtext": term
|
"searchtext": term
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_json(
|
data = helpers.request_json(
|
||||||
url='https://www.nzbsrus.com/api.php',
|
url='https://www.nzbsrus.com/api.php',
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
validator=lambda x: type(x) == dict
|
validator=lambda x: type(x) == dict
|
||||||
@@ -619,7 +524,7 @@ def searchNZB(album, new=False, losslessOnly=False):
|
|||||||
"search": term
|
"search": term
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_json(
|
data = helpers.request_json(
|
||||||
url='http://api.omgwtfnzbs.org/json/',
|
url='http://api.omgwtfnzbs.org/json/',
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
validator=lambda x: type(x) == dict
|
validator=lambda x: type(x) == dict
|
||||||
@@ -840,7 +745,7 @@ def getresultNZB(result):
|
|||||||
nzb = None
|
nzb = None
|
||||||
|
|
||||||
if result[3] == 'newzbin':
|
if result[3] == 'newzbin':
|
||||||
response = request_response(
|
response = helpers.request_response(
|
||||||
url='https://www.newzbin2.es/api/dnzb/',
|
url='https://www.newzbin2.es/api/dnzb/',
|
||||||
auth=(headphones.HPUSER, headphones.HPPASS),
|
auth=(headphones.HPUSER, headphones.HPPASS),
|
||||||
params={"username": headphones.NEWZBIN_UID, "password": headphones.NEWZBIN_PASSWORD, "reportid": result[2]},
|
params={"username": headphones.NEWZBIN_UID, "password": headphones.NEWZBIN_PASSWORD, "reportid": result[2]},
|
||||||
@@ -865,13 +770,13 @@ def getresultNZB(result):
|
|||||||
else:
|
else:
|
||||||
nzb = response.content
|
nzb = response.content
|
||||||
elif result[3] == 'headphones':
|
elif result[3] == 'headphones':
|
||||||
nzb = request_content(
|
nzb = helpers.request_content(
|
||||||
url=result[2],
|
url=result[2],
|
||||||
auth=(headphones.HPUSER, headphones.HPPASS),
|
auth=(headphones.HPUSER, headphones.HPPASS),
|
||||||
headers={'User-Agent': USER_AGENT}
|
headers={'User-Agent': USER_AGENT}
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
nzb = request_content(
|
nzb = helpers.request_content(
|
||||||
url=result[2],
|
url=result[2],
|
||||||
headers={'User-Agent': USER_AGENT}
|
headers={'User-Agent': USER_AGENT}
|
||||||
)
|
)
|
||||||
@@ -962,7 +867,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
"rss": "1"
|
"rss": "1"
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url=providerurl,
|
url=providerurl,
|
||||||
params=params,
|
params=params,
|
||||||
timeout=20
|
timeout=20
|
||||||
@@ -981,7 +886,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
url = item['links'][1]['href']
|
url = item['links'][1]['href']
|
||||||
size = int(item['links'][1]['length'])
|
size = int(item['links'][1]['length'])
|
||||||
if format == "2":
|
if format == "2":
|
||||||
torrent = request_content(url)
|
torrent = helpers.request_content(url)
|
||||||
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
||||||
rightformat = False
|
rightformat = False
|
||||||
if rightformat == True and size < maxsize and minimumseeders < int(seeders):
|
if rightformat == True and size < maxsize and minimumseeders < int(seeders):
|
||||||
@@ -1035,7 +940,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
"q": " ".join(query_items)
|
"q": " ".join(query_items)
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url=providerurl,
|
url=providerurl,
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
timeout=20
|
timeout=20
|
||||||
@@ -1285,7 +1190,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
"sort": "seeds"
|
"sort": "seeds"
|
||||||
}
|
}
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url=providerurl,
|
url=providerurl,
|
||||||
params=params, headers=headers,
|
params=params, headers=headers,
|
||||||
auth=(headphones.HPUSER, headphones.HPPASS),
|
auth=(headphones.HPUSER, headphones.HPPASS),
|
||||||
@@ -1310,7 +1215,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
url = item.links[1]['url']
|
url = item.links[1]['url']
|
||||||
size = int(item.links[1]['length'])
|
size = int(item.links[1]['length'])
|
||||||
if format == "2":
|
if format == "2":
|
||||||
torrent = request_content(url)
|
torrent = helpers.request_content(url)
|
||||||
|
|
||||||
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
||||||
rightformat = False
|
rightformat = False
|
||||||
@@ -1345,7 +1250,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
# Requesting content
|
# Requesting content
|
||||||
logger.info('Parsing results from Mininova')
|
logger.info('Parsing results from Mininova')
|
||||||
|
|
||||||
data = request_feed(
|
data = helpers.request_feed(
|
||||||
url=providerurl,
|
url=providerurl,
|
||||||
timeout=20
|
timeout=20
|
||||||
)
|
)
|
||||||
@@ -1367,7 +1272,7 @@ def searchTorrent(album, new=False, losslessOnly=False):
|
|||||||
url = item.links[1]['url']
|
url = item.links[1]['url']
|
||||||
size = int(item.links[1]['length'])
|
size = int(item.links[1]['length'])
|
||||||
if format == "2":
|
if format == "2":
|
||||||
torrent = request_content(url)
|
torrent = helpers.request_content(url)
|
||||||
|
|
||||||
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
if not torrent or (int(torrent.find(".mp3")) > 0 and int(torrent.find(".flac")) < 1):
|
||||||
rightformat = False
|
rightformat = False
|
||||||
@@ -1408,7 +1313,7 @@ def preprocess(resultlist):
|
|||||||
elif result[3] == 'What.cd':
|
elif result[3] == 'What.cd':
|
||||||
headers = { 'User-Agent': 'Headphones' }
|
headers = { 'User-Agent': 'Headphones' }
|
||||||
|
|
||||||
return request_content(url=result[2], headers=headers), result
|
return helpers.request_content(url=result[2], headers=headers), result
|
||||||
|
|
||||||
else:
|
else:
|
||||||
usenet_retention = headphones.USENET_RETENTION or 2000
|
usenet_retention = headphones.USENET_RETENTION or 2000
|
||||||
|
|||||||
Reference in New Issue
Block a user