Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.py[cod]
23 changes: 23 additions & 0 deletions CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 |
Expand All @@ -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`.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 :) )
Expand Down Expand Up @@ -92,4 +129,3 @@ options:
<p>A: Lower the matchrate, Change the fuzzy_match algorith, Set --fixid3 flag.</p>



45 changes: 38 additions & 7 deletions myx_mam.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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'))
Expand All @@ -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
Expand All @@ -233,4 +265,3 @@ def escape_string(input_string):
escaped_string += char

return escaped_string

4 changes: 3 additions & 1 deletion templates/default_config.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -39,4 +41,4 @@
"title_patterns": ["-end", "\bpart\b", "\btrack\b", "\bof\b", "\bbook\b", "m4b", "\\(", "\\)", "_", "\\[", "\\]", "\\.", "\\s?-\\s?"]
}
}
}
}
86 changes: 86 additions & 0 deletions tests/test_mam_session.py
Original file line number Diff line number Diff line change
@@ -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()