Repository: streamlink/streamlink
Pull request #1724 (2018-06-01 17:14:56)

plugins: store cookies between sessions
Adds some new methods to Plugin to simplify the process of storing and loading cookies between sessions. It makes use of the existing cache mechanism, cookies can be filtered before storing and clearing, and are loaded automatically. 

I wanted this feature to store longer lived cookies for anti-robot scripts, eg. in #1721. It can also be used to store auth cookies. 

codecov[bot]: # [Codecov](https://codecov.io/gh/streamlink/streamlink/pull/1724?src=pr&el=h1) Report
> Merging [#1724](https://codecov.io/gh/streamlink/streamlink/pull/1724?src=pr&el=desc) into [master](https://codecov.io/gh/streamlink/streamlink/commit/d653d569bbe94badb1f8d1edf6eb76d70b8b842e?src=pr&el=desc) will **decrease** coverage by `0.21%`.
> The diff coverage is `93.33%`.


```diff
@@            Coverage Diff             @@
##           master    #1724      +/-   ##
==========================================
- Coverage   37.16%   36.95%   -0.22%     
==========================================
  Files         239      239              
  Lines       13860    13916      +56     
==========================================
- Hits         5151     5142       -9     
- Misses       8709     8774      +65
```


Patch:
diff --git a/.coveragerc b/.coveragerc
index 837acb67..742386c4 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,7 +1,6 @@
 [run]
 source =
-    streamlink
-    streamlink_cli
+    src
 
 [report]
 omit =
diff --git a/docs/players.rst b/docs/players.rst
index e8e94525..7a888bc2 100644
--- a/docs/players.rst
+++ b/docs/players.rst
@@ -29,7 +29,7 @@ modes.
 ===================================================== ========== ========== ====
 Name                                                  Stdin Pipe Named Pipe HTTP
 ===================================================== ========== ========== ====
-`Daum Pot Player <http://potplayer.daum.net>`_        No         No         Yes [1]_
+`Daum Pot Player <http://potplayer.daum.net>`_        Yes        No         Yes [1]_
 `MPC-HC <http://mpc-hc.org/>`_                        Yes [2]_   No         Yes [1]_
 `MPlayer <http://mplayerhq.hu>`_                      Yes        Yes        Yes
 `mpv <http://mpv.io>`_                                Yes        Yes        Yes
diff --git a/src/streamlink/cache.py b/src/streamlink/cache.py
index fee2f49d..63f0bfcd 100644
--- a/src/streamlink/cache.py
+++ b/src/streamlink/cache.py
@@ -90,5 +90,23 @@ class Cache(object):
         else:
             return default
 
+    def get_all(self):
+        ret = {}
+        self._load()
+
+        if self._prune():
+            self._save()
+
+        for key, value in self._cache.items():
+            if self.key_prefix:
+                prefix = self.key_prefix + ":"
+            else:
+                prefix = ""
+            if key.startswith(prefix):
+                okey = key[len(prefix):]
+                ret[okey] = value["value"]
+
+        return ret
+
 
 __all__ = ["Cache"]
diff --git a/src/streamlink/plugin/plugin.py b/src/streamlink/plugin/plugin.py
index ce359174..302f4e9e 100644
--- a/src/streamlink/plugin/plugin.py
+++ b/src/streamlink/plugin/plugin.py
@@ -2,12 +2,15 @@ import ast
 import logging
 import operator
 import re
-from collections import OrderedDict
+import time
+import requests.cookies
+
 from functools import partial
+from collections import OrderedDict
 
-from ..cache import Cache
-from ..exceptions import PluginError, NoStreamsError
-from ..options import Options, Arguments
+from streamlink.cache import Cache
+from streamlink.exceptions import PluginError, NoStreamsError
+from streamlink.options import Options, Arguments
 
 # FIXME: This is a crude attempt at making a bitrate's
 # weight end up similar to the weight of a resolution.
@@ -31,7 +34,6 @@ QUALITY_WEIGTHS_EXTRA = {
     },
 }
 
-
 FILTER_OPERATORS = {
     "<": operator.lt,
     "<=": operator.le,
@@ -39,7 +41,6 @@ FILTER_OPERATORS = {
     ">=": operator.ge,
 }
 
-
 PARAMS_REGEX = r"(\w+)=({.+?}|\[.+?\]|\(.+?\)|'(?:[^'\\]|\\')*'|\"(?:[^\"\\]|\\\")*\"|\S+)"
 
 HIGH_PRIORITY = 30
@@ -177,6 +178,10 @@ class Plugin(object):
 
     def __init__(self, url):
         self.url = url
+        try:
+            self.load_cookies()
+        except RuntimeError:
+            pass  # unbound cannot load
 
     @classmethod
     def can_handle_url(cls, url):
@@ -357,6 +362,7 @@ class Plugin(object):
         def stream_weight_only(s):
             return (self.stream_weight(s)[0] or
                     (len(streams) == 1 and 1))
+
         stream_names = filter(stream_weight_only, streams.keys())
         sorted_streams = sorted(stream_names, key=stream_weight_only)
 
@@ -392,5 +398,90 @@ class Plugin(object):
     def _get_streams(self):
         raise NotImplementedError
 
+    def save_cookies(self, cookie_filter=None, default_expires=60 * 60 * 24 * 7):
+        """
+        Store the cookies from ``http`` in the plugin cache until they expire. The cookies can be filtered
+        by supplying a filter method. eg. ``lambda c: "auth" in c.name``. If no expiry date is given in the
+        cookie then the ``default_expires`` value will be used.
+
+        :param cookie_filter: a function to filter the cookies
+        :type cookie_filter: function
+        :param default_expires: time (in seconds) until cookies with no expiry will expire
+        :type default_expires: int
+        :return: list of the saved cookie names
+        """
+        if not self.session or not self.cache:
+            raise RuntimeError("Cannot cache cookies in unbound plugin")
+
+        cookie_filter = cookie_filter or (lambda c: True)
+        saved = []
+
+        for cookie in filter(cookie_filter, self.session.http.cookies):
+            cookie_dict = {}
+            for attr in ("version", "name", "value", "port", "domain", "path", "secure", "expires", "discard",
+                         "comment", "comment_url", "rfc2109"):
+                cookie_dict[attr] = getattr(cookie, attr, None)
+            cookie_dict["rest"] = getattr(cookie, "rest", getattr(cookie, "_rest", None))
+
+            expires = default_expires
+            if cookie_dict['expires']:
+                expires = int(cookie_dict['expires'] - time.time())
+            key = "__cookie:{0}:{1}:{2}:{3}".format(cookie.name,
+                                                    cookie.domain,
+                                                    cookie.port_specified and cookie.port or "80",
+                                                    cookie.path_specified and cookie.path or "*")
+            self.cache.set(key, cookie_dict, expires)
+            saved.append(cookie.name)
+
+        if saved:
+            self.logger.debug("Saved cookies: {0}".format(", ".join(saved)))
+        return saved
+
+    def load_cookies(self):
+        """
+        Load any stored cookies for the plugin that have not expired.
+
+        :return: list of the restored cookie names
+        """
+        if not self.session or not self.cache:
+            raise RuntimeError("Cannot loaded cached cookies in unbound plugin")
+
+        restored = []
+
+        for key, value in self.cache.get_all().items():
+            if key.startswith("__cookie"):
+                cookie = requests.cookies.create_cookie(**value)
+                self.session.http.cookies.set_cookie(cookie)
+                restored.append(cookie.name)
+
+        if restored:
+            self.logger.debug("Restored cookies: {0}".format(", ".join(restored)))
+        return restored
+
+    def clear_cookies(self, cookie_filter=None):
+        """
+        Removes all of the saved cookies for this Plugin. To filter the cookies that are deleted
+        specify the ``cookie_filter`` argument (see :func:`save_cookies`).
+
+        :param cookie_filter: a function to filter the cookies
+        :type cookie_filter: function
+        :return: list of the removed cookie names
+        """
+        if not self.session or not self.cache:
+            raise RuntimeError("Cannot loaded cached cookies in unbound plugin")
+
+        cookie_filter = cookie_filter or (lambda c: True)
+        removed = []
+
+        for key, value in sorted(self.cache.get_all().items(), key=operator.itemgetter(0), reverse=True):
+            if key.startswith("__cookie"):
+                cookie = requests.cookies.create_cookie(**value)
+                if cookie_filter(cookie):
+                    del self.session.http.cookies[cookie.name]
+                    self.cache.set(key, None, 0)
+                    removed.append(key)
+
+        return removed
+
 
 __all__ = ["Plugin"]
diff --git a/src/streamlink/plugins/crunchyroll.py b/src/streamlink/plugins/crunchyroll.py
index 23286f11..5b6e0ce3 100644
--- a/src/streamlink/plugins/crunchyroll.py
+++ b/src/streamlink/plugins/crunchyroll.py
@@ -1,8 +1,8 @@
 import argparse
+import datetime
 import random
 import re
 import string
-import datetime
 
 from streamlink.plugin import Plugin, PluginError, PluginArguments, PluginArgument
 from streamlink.plugin.api import http, validate
@@ -159,16 +159,11 @@ class CrunchyrollAPI(object):
         Is recommended that you call this method before making any other calls
         to make sure you have a valid session against the server.
         """
-        params = {
-            "device_id": device_id,
-            "device_type": API_DEVICE_TYPE,
-            "access_token": API_ACCESS_TOKEN,
-        }
-
-        if self.auth:
-            params["auth"] = self.auth
-
-        return self._api_call("start_session", params, **kwargs)
+        if self.session_id:
+            http.cookies["sess_id"] = self.session_id
+        res = http.get("https://www.crunchyroll.com",
+                       headers={"User-Agent": API_USER_AGENT.format(self.locale)})
+        return res.cookies.get("sess_id", self.session_id)
 
     def login(self, username, password, **kwargs):
         """Authenticates the session to be able to access restricted data from
@@ -236,7 +231,12 @@ class Crunchyroll(Plugin):
             metavar="SESSION_ID",
             help="""
             Set a specific session ID for crunchyroll, can be used to bypass
-            region restrictions.
+            region restrictions. If using an authenticated session ID, it is
+            recommended that the authentication parameters be omitted as the
+            session ID is account specific.
+
+            Note: The session ID will be overwritten if authentication is used
+            and the session ID does not match the account.
             """
         ),
         # Deprecated, uses the general locale setting
@@ -331,15 +331,7 @@ class Crunchyroll(Plugin):
         )
 
         self.logger.debug("Creating session with locale: {0}", locale)
-        try:
-            api.session_id = api.start_session(device_id, schema=_session_schema)
-        except CrunchyrollAPIError as err:
-            if err.code == "bad_session":
-                self.logger.debug("Current session has expired, creating a new one")
-                api = CrunchyrollAPI(locale=locale)
-                api.session_id = api.start_session(device_id, schema=_session_schema)
-            else:
-                raise err
+        api.session_id = api.start_session(device_id, schema=_session_schema)
 
         # Save session and hope it lasts for a few hours
         self.cache.set("session_id", api.session_id, 4 * 60 * 60)
@@ -349,7 +341,7 @@ class Crunchyroll(Plugin):
             self.logger.debug("Using saved credentials")
         elif self.options.get("username"):
             try:
-                self.logger.info("Attempting to login using username and password")
+                self.logger.debug("Attempting to login using username and password")
                 login = api.login(
                     self.options.get("username"),
                     self.options.get("password"),
@@ -357,6 +349,11 @@ class Crunchyroll(Plugin):
                 )
                 api.auth = login["auth"]
 
+                user_session_id = api.start_session(device_id, schema=_session_schema)
+                if user_session_id != api.session_id:
+                    self.logger.warning("Session ID does not match account, resetting session ID")
+                    api.session_id = user_session_id
+
                 self.logger.info("Successfully logged in as '{0}'",
                                  login["user"]["username"] or login["user"]["email"])
 
diff --git a/src/streamlink/plugins/facebook.py b/src/streamlink/plugins/facebook.py
index c0ded0e0..ee6e4885 100644
--- a/src/streamlink/plugins/facebook.py
+++ b/src/streamlink/plugins/facebook.py
@@ -1,26 +1,37 @@
 import re
 
 from streamlink.plugin import Plugin
-from streamlink.stream import HLSStream
-
-_playlist_url = "https://www.facebook.com/video/playback/playlist.m3u8?v={0}"
-
-_url_re = re.compile(r"http(s)?://(www\.)?facebook\.com/[^/]+/videos/(?P<video_id>\d+)")
+from streamlink.plugin.api import http, useragents
+from streamlink.stream import DASHStream, HTTPStream
+from streamlink.utils import parse_json
 
 
 class Facebook(Plugin):
+    _url_re = re.compile(r"https?://(?:www\.)?facebook\.com/[^/]+/videos")
+    _mpd_re = re.compile(r'''(sd|hd)_src["']?\s*:\s*(?P<quote>["'])(?P<url>.+?)(?P=quote)''')
+    _playlist_re = re.compile(r'''video:\[({url:".+?}\])''')
+    _plurl_re = re.compile(r'''url:"(.*?)"''')
+
     @classmethod
     def can_handle_url(cls, url):
-        return _url_re.match(url)
+        return cls._url_re.match(url)
 
-    @Plugin.broken(990)
     def _get_streams(self):
-        match = _url_re.match(self.url)
-        video = match.group("video_id")
-
-        playlist = _playlist_url.format(video)
-
-        return HLSStream.parse_variant_playlist(self.session, playlist)
+        res = http.get(self.url, headers={"User-Agent": useragents.CHROME})
+
+        for match in self._mpd_re.finditer(res.text):
+            manifest_url = match.group("url")
+            if "\\/" in manifest_url:
+                # if the URL is json encoded, decode it
+                manifest_url = parse_json("\"{}\"".format(manifest_url))
+            for s in DASHStream.parse_manifest(self.session, manifest_url).items():
+                yield s
+        else:
+            match = self._playlist_re.search(res.text)
+            playlist = match and match.group(1)
+            if playlist:
+                for url in {url.group(1) for url in self._plurl_re.finditer(playlist)}:
+                    yield "live", HTTPStream(self.session, url)
 
 
 __plugin__ = Facebook
diff --git a/src/streamlink/plugins/vaughnlive.py b/src/streamlink/plugins/vaughnlive.py
index c998971e..65e7c196 100644
--- a/src/streamlink/plugins/vaughnlive.py
+++ b/src/streamlink/plugins/vaughnlive.py
@@ -40,10 +40,10 @@ class VaughnLive(Plugin):
                                                                                                    range(1, 6))]
     origin = "https://vaughnlive.tv"
     rtmp_server_map = {
-        "594140c69edad": "192.240.105.171:1935",
-        "585c4cab1bef1": "192.240.105.171:1935",
-        "5940d648b3929": "192.240.105.171:1935",
-        "5941854b39bc4": "192.240.105.171:1935"
+        "594140c69edad": "192.240.105.171:2935",
+        "585c4cab1bef1": "192.240.105.171:2935",
+        "5940d648b3929": "192.240.105.171:2935",
+        "5941854b39bc4": "192.240.105.171:2935"
     }
     name_remap = {"#vl": "live", "#btv": "btv", "#pt": "pt", "#igb": "instagib", "#vtv": "vtv"}
     domain_map = {"vaughnlive": "#vl", "breakers": "#btv", "instagib": "#igb", "vapers": "#vtv", "pearltime": "#pt"}
diff --git a/src/streamlink/stream/akamaihd.py b/src/streamlink/stream/akamaihd.py
index c019a5c5..f469442b 100644
--- a/src/streamlink/stream/akamaihd.py
+++ b/src/streamlink/stream/akamaihd.py
@@ -101,8 +101,7 @@ class AkamaiHDStreamIO(io.IOBase):
         url = self.StreamURLFormat.format(host=self.host, streamname=self.streamname)
         params = self._create_params(seek=self.seek)
 
-        log.debug("Opening host={host} streamname={streamname}",
-                          host=self.host, streamname=self.streamname)
+        log.debug("Opening host={} streamname={}", self.host, self.streamname)
 
         try:
             res = self.session.http.get(url, stream=True, params=params)
@@ -202,8 +201,7 @@ class AkamaiHDStreamIO(io.IOBase):
             if isinstance(val, str):
                 val = val[:50]
 
-            log.debug(" {key}={val}",
-                              key=key, val=val)
+            log.debug(" {}={}", key, val)
 
         updateattr("islive", "isLive")
         updateattr("sessionid", "session")
diff --git a/src/streamlink/stream/dash.py b/src/streamlink/stream/dash.py
index 49456404..ae214640 100644
--- a/src/streamlink/stream/dash.py
+++ b/src/streamlink/stream/dash.py
@@ -6,7 +6,7 @@ import os.path
 from streamlink import StreamError, PluginError
 from streamlink.compat import urlparse, urlunparse
 from streamlink.stream.stream import Stream
-from streamlink.stream.dash_manifest import MPD, sleeper, sleep_until, utc
+from streamlink.stream.dash_manifest import MPD, sleeper, sleep_until, utc, freeze_timeline
 from streamlink.stream.ffmpegmux import FFMPEGMuxer
 from streamlink.stream.segmented import SegmentedStreamReader, SegmentedStreamWorker, SegmentedStreamWriter
 
@@ -57,16 +57,19 @@ class DASHStreamWorker(SegmentedStreamWorker):
         self.mpd = self.stream.mpd
         self.period = self.stream.period
 
+    @staticmethod
+    def get_representation(mpd, representation_id):
+        for aset in mpd.periods[0].adaptationSets:
+            for rep in aset.representations:
+                if rep.id == representation_id:
+                    return rep
+
     def iter_segments(self):
         init = True
         back_off_factor = 1
         while not self.closed:
             # find the representation by ID
-            representation = None
-            for aset in self.mpd.periods[0].adaptationSets:
-                for rep in aset.representations:
-                    if rep.id == self.reader.representation_id:
-                        representation = rep
+            representation = self.get_representation(self.mpd, self.reader.representation_id)
             refresh_wait = max(self.mpd.minimumUpdatePeriod.total_seconds(),
                                self.mpd.periods[0].duration.total_seconds()) or 5
             with sleeper(refresh_wait * back_off_factor):
@@ -99,7 +102,10 @@ class DASHStreamWorker(SegmentedStreamWorker):
                       url=self.mpd.url,
                       timelines=self.mpd.timelines)
 
-        changed = new_mpd.publishTime > self.mpd.publishTime
+        new_rep = self.get_representation(new_mpd, self.reader.representation_id)
+        with freeze_timeline(new_mpd):
+            changed = len(list(itertools.islice(new_rep.segments(), 1))) > 0
+
         if changed:
             self.mpd = new_mpd
 
diff --git a/src/streamlink/stream/dash_manifest.py b/src/streamlink/stream/dash_manifest.py
index fdde4ad5..496e1b69 100644
--- a/src/streamlink/stream/dash_manifest.py
+++ b/src/streamlink/stream/dash_manifest.py
@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 
+import copy
 import logging
 import datetime
 import re
@@ -50,6 +51,13 @@ def count_dt(firstval=datetime.datetime.now(tz=utc), step=datetime.timedelta(sec
         x += step
 
 
+@contextmanager
+def freeze_timeline(mpd):
+    timelines = copy.copy(mpd.timelines)
+    yield
+    mpd.timelines = timelines
+
+
 @contextmanager
 def sleeper(duration):
     s = time.time()
@@ -219,6 +227,7 @@ class MPD(MPDNode):
                                                default=datetime.datetime.fromtimestamp(0, utc),  # earliest date
                                                required=self.type == "dynamic")
         self.publishTime = self.attr(u"publishTime", parser=MPDParsers.datetime, required=self.type == "dynamic")
+        self.availabilityEndTime = self.attr(u"availabilityEndTime", parser=MPDParsers.datetime)
         self.mediaPresentationDuration = self.attr(u"mediaPresentationDuration", parser=MPDParsers.duration)
         self.suggestedPresentationDelay = self.attr(u"suggestedPresentationDelay", parser=MPDParsers.duration)
 


Test patch:
diff --git a/tests/streams/test_dash.py b/tests/streams/test_dash.py
index c0e80cd2..26f3a2d0 100644
--- a/tests/streams/test_dash.py
+++ b/tests/streams/test_dash.py
@@ -1,4 +1,5 @@
 from streamlink.stream.dash import DASHStreamWorker
+from streamlink.stream.dash_manifest import MPD
 
 from tests import unittest
 from tests.mock import MagicMock, patch, ANY, Mock, call
@@ -135,7 +136,7 @@ class TestDASHStream(unittest.TestCase):
 
 class TestDASHStreamWorker(unittest.TestCase):
 
-    @patch('streamlink.stream.dash_manifest.time.sleep')
+    @patch("streamlink.stream.dash_manifest.time.sleep")
     @patch('streamlink.stream.dash.MPD')
     def test_dynamic_reload(self, mpdClass, sleep):
         reader = MagicMock()
@@ -169,7 +170,8 @@ class TestDASHStreamWorker(unittest.TestCase):
         self.assertSequenceEqual([next(segment_iter), next(segment_iter)], segments[1:])
         representation.segments.assert_called_with(init=False)
 
-    def test_static(self):
+    @patch("streamlink.stream.dash_manifest.time.sleep")
+    def test_static(self, sleep):
         reader = MagicMock()
         worker = DASHStreamWorker(reader)
         reader.representation_id = 1
diff --git a/tests/test_cache.py b/tests/test_cache.py
index f05f7a74..5d312f44 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -75,3 +75,32 @@ class TestCache(unittest.TestCase):
             self.assertFalse(os.path.exists(cache.filename))
         finally:
             rmtree(streamlink.cache.cache_dir, ignore_errors=True)
+
+    def test_get_all(self):
+        self.cache.set("test1", 1)
+        self.cache.set("test2", 2)
+
+        self.assertDictEqual(
+            {"test1": 1, "test2": 2},
+            self.cache.get_all())
+
+    def test_get_all_prefix(self):
+        self.cache.set("test1", 1)
+        self.cache.set("test2", 2)
+        self.cache.key_prefix = "test"
+        self.cache.set("test3", 3)
+        self.cache.set("test4", 4)
+
+
+        self.assertDictEqual(
+            {"test3": 3, "test4": 4},
+            self.cache.get_all())
+
+    def test_get_all_prune(self):
+        self.cache.set("test1", 1)
+        self.cache.set("test2", 2, -1)
+
+
+        self.assertDictEqual(
+            {"test1": 1},
+            self.cache.get_all())
diff --git a/tests/test_plugin.py b/tests/test_plugin.py
new file mode 100644
index 00000000..8b84ed08
--- /dev/null
+++ b/tests/test_plugin.py
@@ -0,0 +1,144 @@
+import unittest
+import time
+import datetime
+import freezegun
+import requests.cookies
+
+
+from tests.mock import Mock, ANY, call
+from streamlink.plugin import Plugin
+
+
+class TestPlugin(unittest.TestCase):
+
+    def _create_cookie_dict(self, name, value, expires):
+        return {'version': 0, 'name': name, 'value': value,
+                'port': None, 'domain': "test.se", 'path': "/", 'secure': False,
+                'expires': expires, 'discard': True, 'comment': None,
+                'comment_url': None, 'rest': {"HttpOnly": None}, 'rfc2109': False}
+
+    def _cookie_to_dict(self, cookie):
+        r = {}
+        for name in ("version", "name", "value", "port", "domain", "path",
+                     "secure", "expires", "discard", "comment", "comment_url"):
+            r[name] = getattr(cookie, name, None)
+        r["rest"] = getattr(cookie, "rest", getattr(cookie, "_rest", None))
+        return r
+
+    def tearDown(self):
+        Plugin.session = None
+        Plugin.cache = None
+        Plugin.module = None
+        Plugin.logger = None
+
+    def test_cookie_store_save(self):
+        session = Mock()
+        session.http.cookies = [
+            requests.cookies.create_cookie("test-name", "test-value", domain="test.se")
+        ]
+
+        Plugin.bind(session, 'tests.test_plugin')
+        Plugin.cache = Mock()
+        Plugin.cache.get_all.return_value = {}
+
+        plugin = Plugin("http://test.se")
+        plugin.save_cookies(default_expires=3600)
+
+        Plugin.cache.set.assert_called_with("__cookie:test-name:test.se:80:/",
+                                            self._create_cookie_dict("test-name", "test-value", None),
+                                            3600)
+
+    def test_cookie_store_save_expires(self):
+        with freezegun.freeze_time(lambda: datetime.datetime(2018, 1, 1)):
+            session = Mock()
+            session.http.cookies = [
+                requests.cookies.create_cookie("test-name", "test-value", domain="test.se", expires=time.time() + 3600,
+                                               rest={'HttpOnly': None})
+            ]
+
+            Plugin.bind(session, 'tests.test_plugin')
+            Plugin.cache = Mock()
+            Plugin.cache.get_all.return_value = {}
+
+            plugin = Plugin("http://test.se")
+            plugin.save_cookies(default_expires=60)
+
+            Plugin.cache.set.assert_called_with("__cookie:test-name:test.se:80:/",
+                                                self._create_cookie_dict("test-name", "test-value", 1514768400),
+                                                3600)
+
+    def test_cookie_store_load(self):
+        session = Mock()
+        session.http.cookies = requests.cookies.RequestsCookieJar()
+
+        Plugin.bind(session, 'tests.test_plugin')
+        Plugin.cache = Mock()
+        Plugin.cache.get_all.return_value = {
+            "__cookie:test-name:test.se:80:/": self._create_cookie_dict("test-name", "test-value", None)
+        }
+        plugin = Plugin("http://test.se")
+
+        self.assertSequenceEqual(
+            list(map(self._cookie_to_dict, session.http.cookies)),
+            [self._cookie_to_dict(requests.cookies.create_cookie("test-name", "test-value", domain="test.se"))]
+        )
+
+    def test_cookie_store_clear(self):
+        session = Mock()
+        session.http.cookies = requests.cookies.RequestsCookieJar()
+
+        Plugin.bind(session, 'tests.test_plugin')
+        Plugin.cache = Mock()
+        Plugin.cache.get_all.return_value = {
+            "__cookie:test-name:test.se:80:/": self._create_cookie_dict("test-name", "test-value", None),
+            "__cookie:test-name2:test.se:80:/": self._create_cookie_dict("test-name2", "test-value2", None)
+        }
+        plugin = Plugin("http://test.se")
+
+        # non-empty cookiejar
+        self.assertTrue(len(session.http.cookies.get_dict()) > 0)
+
+
+        plugin.clear_cookies()
+        self.assertSequenceEqual(
+            Plugin.cache.set.mock_calls,
+            [call("__cookie:test-name:test.se:80:/", None, 0),
+             call("__cookie:test-name2:test.se:80:/", None, 0)])
+        self.assertSequenceEqual(session.http.cookies, [])
+
+    def test_cookie_store_clear_filter(self):
+        session = Mock()
+        session.http.cookies = requests.cookies.RequestsCookieJar()
+
+        Plugin.bind(session, 'tests.test_plugin')
+        Plugin.cache = Mock()
+        Plugin.cache.get_all.return_value = {
+            "__cookie:test-name:test.se:80:/": self._create_cookie_dict("test-name", "test-value", None),
+            "__cookie:test-name2:test.se:80:/": self._create_cookie_dict("test-name2", "test-value2", None)
+        }
+        plugin = Plugin("http://test.se")
+
+        # non-empty cookiejar
+        self.assertTrue(len(session.http.cookies.get_dict()) > 0)
+
+
+        plugin.clear_cookies(lambda c: c.name.endswith("2"))
+        self.assertSequenceEqual(
+            Plugin.cache.set.mock_calls,
+            [call("__cookie:test-name2:test.se:80:/", None, 0)])
+        self.assertSequenceEqual(
+            list(map(self._cookie_to_dict, session.http.cookies)),
+            [self._cookie_to_dict(requests.cookies.create_cookie("test-name", "test-value", domain="test.se"))]
+        )
+
+    def test_cookie_load_unbound(self):
+        plugin = Plugin("http://test.se")
+        self.assertRaises(RuntimeError, plugin.load_cookies)
+
+    def test_cookie_save_unbound(self):
+        plugin = Plugin("http://test.se")
+        self.assertRaises(RuntimeError, plugin.save_cookies)
+
+    def test_cookie_clear_unbound(self):
+        plugin = Plugin("http://test.se")
+        self.assertRaises(RuntimeError, plugin.clear_cookies)