Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,11 @@ APP_QB_PASSWORD=adminadmin
# ===== MAM auth =====
APP_MAM_USER_AGENT=Scurry/2.0 (+contact)

# ===== Mousehole Integration (Optional) =====
# Enable dynamic MAM token management via mousehole
# See docs/MOUSEHOLE.md for setup instructions
MOUSEHOLE_ENABLED=false
MOUSEHOLE_STATE_FILE=secrets/state.json

# ===== Browser-exposed defaults (safe only) =====
NEXT_PUBLIC_DEFAULT_CATEGORY=books
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ Scurry will organize torrents into the following categories based on their MAM t

To take advantage of this, simply create those categories - `books` and `audiobooks` - in qBittorrent, with unique save paths.

### Mousehole Integration (Optional)

Scurry supports integration with [mousehole](https://github.com/t-mart/mousehole) for automatic MAM token management. When enabled, mousehole dynamically manages your MAM session tokens, eliminating the need for manual updates when your IP address changes.

**Benefits:**
- Automatic token rotation when IP changes
- No manual token updates required
- Perfect for dynamic IPs or VPN/seedbox setups

See the [Mousehole Integration Guide](docs/MOUSEHOLE.md) for detailed setup instructions.

### URL Query String Support

You can pre-fill search terms by adding a `q` parameter to the URL:
Expand Down
65 changes: 55 additions & 10 deletions app/api/mam-token/route.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,77 @@
import { NextResponse } from "next/server";
import { validateMamToken } from "@/src/lib/utilities";
import { MAM_TOKEN_FILE } from "@/src/lib/constants";
import { readMamToken } from "@/src/lib/config";
import { readMamToken, isMouseholeMode, config } from "@/src/lib/config";
import fs from "node:fs";
import path from "node:path";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// Helper function to mask token for display
function maskToken(token) {
return token.length > 10
? `${token.slice(0, 6)}...${token.slice(-4)}`
: token.length > 0 ? "***" : "";
}

// Get current token
export async function GET() {
try {
const mouseholeEnabled = isMouseholeMode();

if (mouseholeEnabled) {
try {
const stateFile = config.mouseholeStateFile;
const state = JSON.parse(fs.readFileSync(stateFile, "utf8"));
const decodedToken = decodeURIComponent(state.currentCookie);

return NextResponse.json({
exists: true,
token: maskToken(decodedToken),
fullLength: decodedToken.length,
location: stateFile,
mouseholeInfo: {
enabled: true,
stateFile,
lastUpdate: state.lastUpdate?.at || state.lastMam?.request?.at,
mamUpdated: state.lastUpdate?.mamUpdated,
},
});
} catch (err) {
// Fall through to regular token file check
console.warn("Mousehole read failed, checking static token:", err.message);
}
}

const exists = fs.existsSync(MAM_TOKEN_FILE);

if (!exists) {
return NextResponse.json({
exists: false,
token: null,
location: MAM_TOKEN_FILE
location: MAM_TOKEN_FILE,
mouseholeInfo: mouseholeEnabled ? { enabled: true, error: "state.json not found" } : { enabled: false },
});
}

const token = readMamToken();

// Don't send the full token for security - just first/last few chars
const maskedToken = token.length > 10
? `${token.slice(0, 6)}...${token.slice(-4)}`
: token.length > 0 ? "***" : "";

return NextResponse.json({
exists: true,
token: maskedToken,
token: maskToken(token),
fullLength: token.length,
location: MAM_TOKEN_FILE
location: MAM_TOKEN_FILE,
mouseholeInfo: { enabled: false },
});
} catch (error) {
console.error(`Failed to read MAM token: ${error.message}`);
return NextResponse.json(
{
exists: false,
error: error.message,
location: MAM_TOKEN_FILE
location: MAM_TOKEN_FILE,
mouseholeInfo: { enabled: isMouseholeMode() },
},
{ status: 500 }
);
Expand All @@ -49,6 +80,13 @@ export async function GET() {

// Update/create token
export async function POST(req) {
if (isMouseholeMode()) {
return NextResponse.json(
{ error: "Token management is disabled when MOUSEHOLE_ENABLED=true. Tokens are managed by mousehole." },
{ status: 400 }
);
}

try {
const body = await req.json();
const { token } = body;
Expand Down Expand Up @@ -108,6 +146,13 @@ export async function POST(req) {

// Delete token
export async function DELETE() {
if (isMouseholeMode()) {
return NextResponse.json(
{ error: "Token management is disabled when MOUSEHOLE_ENABLED=true. Tokens are managed by mousehole." },
{ status: 400 }
);
}

try {
if (fs.existsSync(MAM_TOKEN_FILE)) {
fs.unlinkSync(MAM_TOKEN_FILE);
Expand Down
Loading