diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/CONFIG.md b/CONFIG.md index f654ff8..4e116f6 100644 --- a/CONFIG.md +++ b/CONFIG.md @@ -12,6 +12,8 @@ A copy of default_config.cfg can be found under the /templates folder. It is re "log_path": "/logs", "cache_path": "/config", "session": "", + "mousehole_enabled": 0, + "mousehole_state_file": "/app/secrets/state.json", "paths": [{ "files": ["**/*.m4b", "**/*.mp3", "**/*.m4a"], "source_path": "/path/to/downloads", @@ -53,6 +55,8 @@ A copy of default_config.cfg can be found under the /templates folder. It is re | log_path | | Where your log files will be saved. If not set, will default to "logs" | /logs (for docker), logs (for local) | | cache_path | | Where your log files will be saved. If not set, will default to "logs" | /config | | session | | MAM Session ID (can be removed once one has been saved) | | +| mousehole_enabled | | If true, read the MAM session cookie from a mousehole state file instead of relying on a static session ID | 0 | +| mousehole_state_file | | Path to mousehole's state.json file. The file should include `currentCookie` | /app/secrets/state.json | | paths | | This is a *list* of folders and files to be processed | | | files | File patterns to be searched | ["\*\*/\*.m4b", "\*\*/\*.mp3", "\*\*/\*.m4a"] | | | source_path | Unorganized folder location | /path/to/downloads | @@ -78,3 +82,22 @@ A copy of default_config.cfg can be found under the /templates folder. It is re | | kw_ignore | Characters ignored when generating keywords for search | | [".", ":", "_", "[", "]", "{", "}", ",", ";", "(", ")"] | | | kw_ignore_words | Characters ignored when optimizing keywords for search| ["the","and","m4b","mp3","series","audiobook","audiobooks", "book", "part", "track", "novel", "disc"] | | | title_patterns | ignores these patterns when alt Title is generated from bad id3 data | ["-end", "\bpart\b", "\btrack\b", "\bof\b", "\bbook\b", "m4b", "\\(", "\\)", "_", "\\[", "\\]", "\\.", "\\s?-\\s?"] | + +## Mousehole + +booktree can read the current MAM session cookie from [mousehole](https://github.com/t-mart/mousehole), which avoids manually updating `Config/session` when your IP changes. Enable it in your config file: + +~~~ +"mousehole_enabled": 1, +"mousehole_state_file": "/app/secrets/state.json" +~~~ + +mousehole writes a JSON state file with a URL-encoded `currentCookie` value: + +~~~ +{ + "currentCookie": "your%2Bmam%2Fsession%2Fcookie" +} +~~~ + +When `mousehole_enabled` is true, booktree reads and URL-decodes `currentCookie` for MAM API calls. If the state file is missing or invalid, booktree falls back to `Config/session`. diff --git a/README.md b/README.md index 97672bd..2a1ba51 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,43 @@ options: 2. copy default_config.cfg into config.json and modify with your paths settings (files, source_path, media_path) 3. if using MAM as a source, create a MAM session ID and set the value in config.json file (/Config/session) +### Mousehole Integration + +booktree can use [mousehole](https://github.com/t-mart/mousehole) to read the current MAM session cookie from mousehole's `state.json` file. This avoids manually updating `/Config/session` when your IP address changes and mousehole rotates the cookie. + +Enable mousehole in your config: + +~~~ +"mousehole_enabled": 1, +"mousehole_state_file": "/app/secrets/state.json" +~~~ + +The mousehole state file should contain a URL-encoded `currentCookie` value: + +~~~ +{ + "currentCookie": "your%2Bmam%2Fsession%2Fcookie" +} +~~~ + +When mousehole is enabled, booktree uses `currentCookie` directly and ignores any cached `cookies.pkl` so token rotation is picked up immediately. If the state file is missing or invalid, booktree falls back to `/Config/session`. + +For Docker, mount the same mousehole data directory into the booktree container and point `mousehole_state_file` at the mounted state file: + +~~~ +services: + mousehole: + image: tmmrtn/mousehole:latest + volumes: + - /path/to/mousehole:/srv/mousehole + + booktree: + volumes: + - /path/to/mousehole:/app/secrets + - /path/to/booktree/config:/config + - /path/to/logs:/logs +~~~ + ### Recommended Workflow 1. Start small (pick a folder that has a handful of books, don't run it on 2K files the first try :) ) @@ -92,4 +129,3 @@ options:
A: Lower the matchrate, Change the fuzzy_match algorith, Set --fixid3 flag.
- diff --git a/myx_mam.py b/myx_mam.py index 9cbef0b..11cedc4 100644 --- a/myx_mam.py +++ b/myx_mam.py @@ -2,17 +2,44 @@ import json import os import pickle +from urllib.parse import unquote from pprint import pprint import myx_classes import myx_utilities #MAM Functions +def isMouseholeEnabled(cfg): + mousehole_enabled = cfg.get("Config/mousehole_enabled", 0) + if isinstance(mousehole_enabled, str): + return mousehole_enabled.lower() in ("1", "true", "yes", "on") + return bool(mousehole_enabled) + +def getMAMSession(cfg): + session = cfg.get("Config/session") + + if isMouseholeEnabled(cfg): + mousehole_state_file = cfg.get("Config/mousehole_state_file") + try: + if mousehole_state_file is None or not len(mousehole_state_file): + raise Exception("mousehole_state_file is not configured") + with open(mousehole_state_file) as state_file: + state = json.loads(state_file.read()) + mousehole_session = unquote(state["currentCookie"]).strip() + if len(mousehole_session): + return mousehole_session + print("Mousehole state file did not contain a session cookie. Checking session ID from config...") + except Exception as e: + print(f"Failed to read mousehole state file: {e}. Checking session ID from config...") + + return session + def searchMAM(cfg, titleFilename, authors, extension): #Config - session = cfg.get("Config/session") + session = getMAMSession(cfg) log_path = cfg.get("Config/log_path") verbose = bool(cfg.get("Config/flags/verbose")) + mousehole_enabled = isMouseholeEnabled(cfg) ebook = bool(cfg.get("Config/flags/ebooks")) audiobook = not (ebook) @@ -39,8 +66,10 @@ def searchMAM(cfg, titleFilename, authors, extension): cookies_filepath = os.path.join(log_path, 'cookies.pkl') sess = requests.Session() + if mousehole_enabled: + sess.headers.update({"cookie": f"mam_id={session}"}) #a cookie file exists, use that - if os.path.exists(cookies_filepath): + elif os.path.exists(cookies_filepath): cookies = pickle.load(open(cookies_filepath, 'rb')) sess.cookies = cookies else: @@ -176,16 +205,19 @@ def testSessionCookie(mySession): def checkMAMCookie(cfg): #Config - session = cfg.get("Config/session") + session = getMAMSession(cfg) log_path = cfg.get("Config/log_path") + mousehole_enabled = isMouseholeEnabled(cfg) isCookieValid = False - useConfigSession = False + useConfigSession = mousehole_enabled cookies_filepath = os.path.join(log_path, 'cookies.pkl') sess = requests.Session() #Check if a cookie file exists - if os.path.exists(cookies_filepath): + if mousehole_enabled: + print (f"Checking MAM cookie from mousehole state file...") + elif os.path.exists(cookies_filepath): print (f"Checking if current cookie file is still valid...") #If it does, create a session, using this cookie cookies = pickle.load(open(cookies_filepath, 'rb')) @@ -210,7 +242,7 @@ def checkMAMCookie(cfg): isCookieValid = testSessionCookie (sess) else: - print (f"No session ID found in the config... Please go to MAM Preferences > Security to create a new session") + print (f"No session ID found in the config or mousehole state file... Please go to MAM Preferences > Security to create a new session") return isCookieValid @@ -233,4 +265,3 @@ def escape_string(input_string): escaped_string += char return escaped_string - diff --git a/templates/default_config.cfg b/templates/default_config.cfg index 8f38d83..bd37de0 100644 --- a/templates/default_config.cfg +++ b/templates/default_config.cfg @@ -7,6 +7,8 @@ "cache_path": "/config", "last_scan": "logs/booktree_log.csv", "session": "", + "mousehole_enabled": 0, + "mousehole_state_file": "/app/secrets/state.json", "paths": [{ "files": ["**/*.m4b", "**/*.mp3", "**/*.m4a"], "source_path": "/data/torrents/downloads", @@ -39,4 +41,4 @@ "title_patterns": ["-end", "\bpart\b", "\btrack\b", "\bof\b", "\bbook\b", "m4b", "\\(", "\\)", "_", "\\[", "\\]", "\\.", "\\s?-\\s?"] } } -} \ No newline at end of file +} diff --git a/tests/test_mam_session.py b/tests/test_mam_session.py new file mode 100644 index 0000000..afd0abe --- /dev/null +++ b/tests/test_mam_session.py @@ -0,0 +1,86 @@ +import json +import os +import sys +import tempfile +import types +import unittest + +sys.modules.setdefault("requests", types.SimpleNamespace()) +sys.modules.setdefault("myx_classes", types.SimpleNamespace()) +sys.modules.setdefault("myx_utilities", types.SimpleNamespace()) + +import myx_mam + + +class FakeConfig: + def __init__(self, data): + self.data = data + + def get(self, path=None, default=None): + value = self.data + for part in path.split("/"): + if not isinstance(value, dict) or part not in value: + return default + value = value[part] + return value + + +class MamSessionTests(unittest.TestCase): + def cfg(self, **values): + config = { + "session": "static-token", + "mousehole_enabled": 0, + "mousehole_state_file": "", + } + config.update(values) + return FakeConfig({"Config": config}) + + def write_state(self, state): + tmp = tempfile.NamedTemporaryFile("w", delete=False) + self.addCleanup(lambda: os.path.exists(tmp.name) and os.remove(tmp.name)) + with tmp: + json.dump(state, tmp) + return tmp.name + + def test_static_session_is_used_when_mousehole_is_disabled(self): + cfg = self.cfg(session="static-token", mousehole_enabled=0) + + self.assertEqual(myx_mam.getMAMSession(cfg), "static-token") + + def test_mousehole_session_is_read_and_decoded(self): + state_file = self.write_state({"currentCookie": "token%2Bwith%2Fencoding"}) + cfg = self.cfg(mousehole_enabled=1, mousehole_state_file=state_file) + + self.assertEqual(myx_mam.getMAMSession(cfg), "token+with/encoding") + + def test_missing_mousehole_state_falls_back_to_static_session(self): + cfg = self.cfg( + session="fallback-token", + mousehole_enabled=1, + mousehole_state_file="/path/does/not/exist", + ) + + self.assertEqual(myx_mam.getMAMSession(cfg), "fallback-token") + + def test_invalid_mousehole_state_falls_back_to_static_session(self): + state_file = self.write_state({"unexpected": "value"}) + cfg = self.cfg( + session="fallback-token", + mousehole_enabled=1, + mousehole_state_file=state_file, + ) + + self.assertEqual(myx_mam.getMAMSession(cfg), "fallback-token") + + def test_empty_static_session_remains_empty_when_mousehole_fallback_fails(self): + cfg = self.cfg( + session="", + mousehole_enabled=1, + mousehole_state_file="/path/does/not/exist", + ) + + self.assertEqual(myx_mam.getMAMSession(cfg), "") + + +if __name__ == "__main__": + unittest.main()