diff --git a/home/profiles/cal/default.nix b/home/profiles/cal/default.nix index 27811d2..4b261b4 100644 --- a/home/profiles/cal/default.nix +++ b/home/profiles/cal/default.nix @@ -1,4 +1,9 @@ -{ pkgs, config, age, ... }: +{ + pkgs, + config, + age, + ... +}: { home.packages = with pkgs; [ @@ -16,7 +21,7 @@ a = "contacts_local" b = "contacts_remote" collections = ["from b"] - metadata = ["displayname"] + metadata = ["displayname" "color"] [storage contacts_local] type = "filesystem" @@ -33,7 +38,7 @@ a = "calendar_local" b = "calendar_remote" collections = ["from b"] - metadata = ["displayname"] + metadata = ["displayname" "color"] [storage calendar_local] type = "filesystem" diff --git a/home/profiles/opencode/agents/archivist.md.disabled b/home/profiles/opencode/agents/archivist.md.disabled new file mode 100644 index 0000000..cba22ca --- /dev/null +++ b/home/profiles/opencode/agents/archivist.md.disabled @@ -0,0 +1,73 @@ +--- +description: Search through past OpenCode sessions to find relevant context, previous solutions, and historical decisions. Use this when you need to recall how something was done before or find related past work. +mode: subagent +model: anthropic/claude-haiku-4-5 +temperature: 0.1 +tools: + "*": false + search-history: true + skill: true +permission: + skill: + "session-search": allow + "*": deny +--- + +You are the Archivist, a specialized agent that searches through OpenCode session history to find relevant past conversations, code changes, and decisions. + +You are running inside an AI coding system as a subagent. The main agent invokes you when it needs to find relevant context from previous sessions. + +## Your Purpose + +When invoked, you will: +1. Search through the local OpenCode session history +2. Find sessions and messages relevant to the query +3. Synthesize findings into a clear, actionable answer + +## How to Search + +First, load the `session-search` skill to understand the search strategies and storage structure. + +Then use the `search-history` tool to find relevant sessions. You can: +- Search by keywords, code patterns, file names, or concepts +- Filter by project directory if the query is project-specific +- List recent sessions to get an overview + +## Search Strategies + +1. **Start broad**: Use general keywords related to the query +2. **Refine**: If too many results, add more specific terms or filter by directory +3. **Cross-reference**: Search for related terms (e.g., if searching for "auth", also try "login", "authentication") +4. **Check context**: Look at session titles and directories to understand the context + +## Response Format + +Your response should directly answer the question posed, using information from past sessions: + +1. **Direct answer**: What was found that addresses the question +2. **Relevant sessions**: List session IDs where this was discussed (so user can resume if needed) +3. **Key details**: Important snippets or decisions from the history + +Example response: +``` +Based on past sessions, authentication was implemented using JWT tokens with a 24-hour expiry. + +**Relevant sessions:** +- ses_abc123 - "Implementing user auth" (2024-01-15) +- ses_def456 - "Auth token refresh" (2024-01-20) + +**Key details:** +- Tokens are stored in httpOnly cookies +- Refresh endpoint at /api/auth/refresh +- Used jose library for JWT handling +``` + +## Guidelines + +- Be concise and direct - the main agent needs actionable information +- Include session IDs so the user can explore further if needed +- If nothing relevant is found, say so clearly +- Focus on answering the specific question, not providing exhaustive history +- Never fabricate information - only report what's actually in the history + +IMPORTANT: Your final message is returned to the main agent. Make it comprehensive but focused on answering the original question. diff --git a/home/profiles/opencode/skills/session-search.disabled/SKILL.md b/home/profiles/opencode/skills/session-search.disabled/SKILL.md new file mode 100644 index 0000000..ae034dc --- /dev/null +++ b/home/profiles/opencode/skills/session-search.disabled/SKILL.md @@ -0,0 +1,104 @@ +--- +name: session-search +description: Advanced strategies for searching OpenCode session history. Restricted to the archivist agent. +--- + +# Session Search Skill + +This skill provides advanced strategies for searching through OpenCode's session history storage. + +## Storage Structure + +OpenCode stores data in `~/.local/share/opencode/storage/`: + +``` +storage/ +├── session/ # Session metadata by project +│ └── {projectHash}/ +│ └── ses_*.json # Session info (title, directory, timestamps) +├── message/ # Messages organized by session +│ └── ses_*/ +│ └── msg_*.json # Message metadata (role, agent, model) +├── part/ # Actual message content +│ └── msg_*/ +│ └── prt_*.json # Content parts (text, tool calls) +└── project/ # Project metadata + └── {hash}.json # Worktree path, timestamps +``` + +## Search Tool Usage + +The `search-history` tool accepts: +- `query`: Text pattern to search for (searches message content) +- `directory`: Optional filter by project path (partial match) +- `limit`: Max results (default 30) + +### Examples + +```typescript +// Find all sessions mentioning "authentication" +search-history({ query: "authentication" }) + +// Find sessions in a specific project +search-history({ query: "database", directory: "myproject" }) + +// List recent sessions (empty query) +search-history({ query: "", limit: 20 }) +``` + +## Search Strategies + +### 1. Keyword Expansion +Don't just search for the exact term. Try synonyms and related concepts: +- "auth" → also try "login", "authentication", "jwt", "token" +- "database" → also try "postgres", "sqlite", "db", "migration" +- "api" → also try "endpoint", "route", "handler" + +### 2. Code Pattern Search +Search for code-specific patterns: +- Function names: `handleAuth`, `validateToken` +- File paths: `src/auth`, `lib/database` +- Import statements: `import.*prisma` +- Error messages: specific error text + +### 3. Tool Usage Search +Find when specific tools were used: +- Edit operations: search for file paths that were edited +- Bash commands: search for command names +- Specific operations: "git push", "npm install" + +### 4. Directory Filtering +Use the `directory` parameter to scope searches: +- Filter by project name: `directory: "myapp"` +- Filter by path segment: `directory: "usr/projects"` + +### 5. Iterative Refinement +1. Start with broad search +2. If too many results, add specificity +3. If no results, broaden or try alternative terms +4. Cross-reference multiple searches + +## Understanding Results + +### Session Info +- `id`: Unique session identifier (can be used to reference) +- `title`: Auto-generated session title +- `directory`: Project worktree path +- `updated`: Last activity timestamp + +### Content Matches +- `sessionID`: Which session contains this match +- `snippet`: Context around the match (±100 chars) +- `role`: user/assistant/tool + +## Tips + +1. **Recent vs Relevant**: The tool returns recent sessions first. Older but more relevant sessions may be further in results. + +2. **Title Search**: Session titles are auto-generated from the conversation and can be good search targets. + +3. **Multiple Searches**: Don't hesitate to run multiple searches with different terms to build a complete picture. + +4. **Context Matters**: The snippet provides limited context. Session titles and directories help understand the broader context. + +5. **No Results**: If no results found, the pattern may be too specific. Try shorter or more general terms. diff --git a/home/profiles/opencode/tools/.gitignore b/home/profiles/opencode/tools/.gitignore new file mode 100644 index 0000000..d77474a --- /dev/null +++ b/home/profiles/opencode/tools/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +bun.lock diff --git a/home/profiles/opencode/tools/package.json b/home/profiles/opencode/tools/package.json new file mode 100644 index 0000000..06d30e7 --- /dev/null +++ b/home/profiles/opencode/tools/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@opencode-ai/plugin": "1.1.31" + } +} diff --git a/home/profiles/opencode/tools/search-history.ts.disabled b/home/profiles/opencode/tools/search-history.ts.disabled new file mode 100644 index 0000000..4c287ef --- /dev/null +++ b/home/profiles/opencode/tools/search-history.ts.disabled @@ -0,0 +1,275 @@ +import { tool } from "@opencode-ai/plugin" +import { $ } from "bun" +import { readdir, readFile } from "fs/promises" +import { join } from "path" +import { homedir } from "os" + +const STORAGE_PATH = join(homedir(), ".local/share/opencode/storage") + +interface SessionInfo { + id: string + title: string + directory: string + projectID: string + created: number + updated: number +} + +interface MessageMatch { + sessionID: string + messageID: string + snippet: string + role: string + timestamp?: number +} + +interface SearchResult { + sessions: SessionInfo[] + matches: MessageMatch[] + totalMatches: number +} + +async function getSessionInfo(sessionID: string): Promise { + try { + // Sessions are stored in directories named by project hash + const sessionDirs = await readdir(join(STORAGE_PATH, "session")) + for (const dir of sessionDirs) { + const sessionPath = join(STORAGE_PATH, "session", dir) + const files = await readdir(sessionPath).catch(() => []) + for (const file of files) { + if (file.startsWith(sessionID) || file.includes(sessionID)) { + const content = await readFile(join(sessionPath, file), "utf-8") + const data = JSON.parse(content) + return { + id: data.id, + title: data.title || "Untitled", + directory: data.directory || "", + projectID: data.projectID || "", + created: data.time?.created || 0, + updated: data.time?.updated || 0, + } + } + } + } + } catch { + // Fall back to searching message directories + } + return null +} + +async function searchWithRipgrep( + pattern: string, + directory?: string, + limit: number = 50 +): Promise { + const matches: MessageMatch[] = [] + const sessionIDs = new Set() + + // Search through part storage (contains actual message content) + const partPath = join(STORAGE_PATH, "part") + + try { + // Use ripgrep to search JSON files, extracting context around matches + const rgResult = await $`rg -i -l ${pattern} ${partPath} --type json 2>/dev/null || true`.text() + const matchingFiles = rgResult.trim().split("\n").filter(Boolean) + + for (const file of matchingFiles.slice(0, limit * 2)) { + try { + const content = await readFile(file, "utf-8") + const data = JSON.parse(content) + + // Filter by directory if specified + if (directory) { + const sessionInfo = await getSessionInfo(data.sessionID) + if (sessionInfo && !sessionInfo.directory.includes(directory)) { + continue + } + } + + // Extract snippet around the match + const text = data.text || data.content || JSON.stringify(data) + const lowerText = text.toLowerCase() + const lowerPattern = pattern.toLowerCase() + const matchIndex = lowerText.indexOf(lowerPattern) + + if (matchIndex !== -1) { + const start = Math.max(0, matchIndex - 100) + const end = Math.min(text.length, matchIndex + pattern.length + 100) + const snippet = (start > 0 ? "..." : "") + + text.slice(start, end) + + (end < text.length ? "..." : "") + + matches.push({ + sessionID: data.sessionID, + messageID: data.messageID, + snippet: snippet.replace(/\n/g, " ").trim(), + role: data.role || "unknown", + timestamp: data.time?.created, + }) + + sessionIDs.add(data.sessionID) + + if (matches.length >= limit) break + } + } catch { + // Skip files that can't be parsed + } + } + } catch (e) { + // ripgrep not found or error + } + + // Also search message metadata for titles + const messagePath = join(STORAGE_PATH, "message") + try { + const rgResult = await $`rg -i -l ${pattern} ${messagePath} --type json 2>/dev/null || true`.text() + const matchingFiles = rgResult.trim().split("\n").filter(Boolean) + + for (const file of matchingFiles.slice(0, 20)) { + try { + const content = await readFile(file, "utf-8") + const data = JSON.parse(content) + if (data.sessionID) { + sessionIDs.add(data.sessionID) + } + } catch { + // Skip + } + } + } catch { + // Ignore errors + } + + // Get session info for all matched sessions + const sessions: SessionInfo[] = [] + for (const sessionID of sessionIDs) { + const info = await getSessionInfo(sessionID) + if (info) { + // Apply directory filter for sessions too + if (!directory || info.directory.includes(directory)) { + sessions.push(info) + } + } + } + + // Sort sessions by most recent + sessions.sort((a, b) => b.updated - a.updated) + + return { + sessions: sessions.slice(0, 20), + matches: matches.slice(0, limit), + totalMatches: matches.length, + } +} + +async function listRecentSessions( + directory?: string, + limit: number = 20 +): Promise { + const sessions: SessionInfo[] = [] + + try { + const sessionDirs = await readdir(join(STORAGE_PATH, "session")) + + for (const dir of sessionDirs) { + const sessionPath = join(STORAGE_PATH, "session", dir) + const files = await readdir(sessionPath).catch(() => []) + + for (const file of files) { + if (!file.endsWith(".json")) continue + try { + const content = await readFile(join(sessionPath, file), "utf-8") + const data = JSON.parse(content) + + if (directory && !data.directory?.includes(directory)) { + continue + } + + sessions.push({ + id: data.id, + title: data.title || "Untitled", + directory: data.directory || "", + projectID: data.projectID || "", + created: data.time?.created || 0, + updated: data.time?.updated || 0, + }) + } catch { + // Skip invalid files + } + } + } + } catch { + // Storage doesn't exist + } + + sessions.sort((a, b) => b.updated - a.updated) + return sessions.slice(0, limit) +} + +export default tool({ + description: + "Search through OpenCode session history to find past conversations, code changes, and decisions. Use this to find relevant context from previous sessions.", + args: { + query: tool.schema + .string() + .describe( + "Search pattern to find in session history. Searches message content, titles, and tool outputs." + ), + directory: tool.schema + .string() + .optional() + .describe( + "Optional: Filter results to sessions from a specific project directory path (partial match)" + ), + limit: tool.schema + .number() + .optional() + .describe("Maximum number of matches to return (default: 30)"), + }, + async execute(args) { + const limit = args.limit || 30 + + if (!args.query || args.query.trim() === "") { + // List recent sessions if no query + const sessions = await listRecentSessions(args.directory, limit) + return JSON.stringify( + { + type: "recent_sessions", + sessions, + message: `Found ${sessions.length} recent sessions${args.directory ? ` in ${args.directory}` : ""}`, + }, + null, + 2 + ) + } + + const results = await searchWithRipgrep(args.query, args.directory, limit) + + // Format output for the agent + let output = `## Search Results for "${args.query}"\n\n` + + if (results.sessions.length > 0) { + output += `### Relevant Sessions (${results.sessions.length})\n\n` + for (const session of results.sessions) { + const date = new Date(session.updated).toLocaleDateString() + output += `- **${session.title}** (${session.id})\n` + output += ` - Directory: \`${session.directory}\`\n` + output += ` - Last updated: ${date}\n\n` + } + } + + if (results.matches.length > 0) { + output += `### Content Matches (${results.totalMatches})\n\n` + for (const match of results.matches.slice(0, 15)) { + output += `**Session:** ${match.sessionID}\n` + output += `> ${match.snippet}\n\n` + } + } + + if (results.sessions.length === 0 && results.matches.length === 0) { + output += `No matches found for "${args.query}"${args.directory ? ` in ${args.directory}` : ""}\n` + } + + return output + }, +}) diff --git a/hosts/profiles/jacket/default.nix b/hosts/profiles/jacket/default.nix index 2ee3962..35f4670 100644 --- a/hosts/profiles/jacket/default.nix +++ b/hosts/profiles/jacket/default.nix @@ -5,24 +5,6 @@ ... }: { - services.jackett = { - enable = true; - port = 8011; - user = "jackett"; - group = "transmission"; - package = pkgs.unstable.jackett; - }; - services.nginx.virtualHosts."jackett.mossnet.lan" = { - enableACME = false; - forceSSL = false; - - locations."/" = { - extraConfig = '' - proxy_pass http://127.0.0.1:8011/; - ''; - }; - }; - services.lidarr = { enable = true; group = "audio"; diff --git a/hosts/profiles/sync/music/get-music.sh b/hosts/profiles/sync/music/get-music.sh index 8f511ab..e659bdc 100755 --- a/hosts/profiles/sync/music/get-music.sh +++ b/hosts/profiles/sync/music/get-music.sh @@ -3,7 +3,7 @@ set -euo pipefail REMOTE_HOST="aynish@talos.feralhosting.com" -REMOTE_PATH="private/transmission/data/" +REMOTE_PATH="private/transmission/data/lidarr/" LOCAL_PATH="/tank/new-music" TRACKING_FILE="/tank/new-music/.downloaded_albums" LOG_FILE="/tank/new-music/download-log" @@ -13,7 +13,7 @@ touch "$TRACKING_FILE" # Get list of albums on remote server echo "$(date): Checking for new albums on seedbox..." >>"$LOG_FILE" -REMOTE_ALBUMS=$(rsync --dry-run --list-only "$REMOTE_HOST:$REMOTE_PATH" | grep '^d' | awk '{$1=$2=$3=$4=""; sub(/^ +/, ""); print}' | grep -v '^\.' | grep -v '^tv-sonarr$') || true +REMOTE_ALBUMS=$(rsync --dry-run -s --list-only "$REMOTE_HOST:$REMOTE_PATH" | grep '^d' | awk '{print substr($0, index($0, $5))}' | grep -v '^\.' | grep -v '^tv-sonarr$') || true if [ -z "$REMOTE_ALBUMS" ]; then echo "$(date): No albums found on remote server" >>"$LOG_FILE" @@ -39,7 +39,7 @@ echo "$(date): Starting download of new albums..." >>"$LOG_FILE" while IFS= read -r album; do if [ -n "$album" ]; then echo "$(date): Downloading $album" >>"$LOG_FILE" - if rsync -r --log-file="$LOG_FILE" "$REMOTE_HOST:$REMOTE_PATH$album/" "$LOCAL_PATH/$album/"; then + if rsync -r -s --log-file="$LOG_FILE" "$REMOTE_HOST:$REMOTE_PATH$album/" "$LOCAL_PATH/$album/"; then echo "$album" >>"$TRACKING_FILE" echo "$(date): Successfully downloaded $album" >>"$LOG_FILE" @@ -47,7 +47,7 @@ while IFS= read -r album; do echo "$(date): Importing $album to beets..." >>"$LOG_FILE" # Set umask to allow group read/write access umask 002 - if beet import -q "$LOCAL_PATH/$album"; then + if beet import -q --quiet-fallback=asis "$LOCAL_PATH/$album"; then echo "$(date): Successfully imported $album to beets" >>"$LOG_FILE" else echo "$(date): Failed to import $album to beets" >>"$LOG_FILE" diff --git a/hosts/profiles/sync/tv/default.nix b/hosts/profiles/sync/tv/default.nix index d6f0b52..33792c4 100644 --- a/hosts/profiles/sync/tv/default.nix +++ b/hosts/profiles/sync/tv/default.nix @@ -5,10 +5,13 @@ serviceConfig.Type = "oneshot"; path = [ pkgs.coreutils + pkgs.gnugrep + pkgs.util-linux pkgs.openssh pkgs.gawk pkgs.rsync pkgs.curl + pkgs.jq ]; script = builtins.readFile ./get-tv.sh; serviceConfig = { diff --git a/hosts/profiles/sync/tv/get-tv.sh b/hosts/profiles/sync/tv/get-tv.sh index 6253168..6df96ae 100644 --- a/hosts/profiles/sync/tv/get-tv.sh +++ b/hosts/profiles/sync/tv/get-tv.sh @@ -7,57 +7,186 @@ REMOTE_PATH="private/transmission/data/tv-sonarr/" LOCAL_PATH="/tank/media/tv" TRACKING_FILE="/tank/media/tv/.downloaded_shows" LOG_FILE="/tank/media/tv/download-log" +LOCK_FILE="/tmp/get-tv-sync.lock" +SONARR_API_KEY="PLACEHOLDER" +SONARR_URL="http://localhost:8989" + +umask 002 # Create local directory and tracking file if they don't exist mkdir -p "$LOCAL_PATH" touch "$TRACKING_FILE" -# Get list of shows on remote server -echo "$(date): Checking for new TV shows on seedbox..." >>"$LOG_FILE" -REMOTE_SHOWS=$(rsync --dry-run --list-only "$REMOTE_HOST:$REMOTE_PATH" | grep '^d' | awk '{$1=$2=$3=$4=""; sub(/^ +/, ""); print}' | grep -v '^\.') || true +log() { echo "$(date): $1" >>"$LOG_FILE"; } -if [ -z "$REMOTE_SHOWS" ]; then - echo "$(date): No shows found on remote server" >>"$LOG_FILE" +# Acquire exclusive lock to prevent concurrent runs +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + log "Another instance is already running, exiting" exit 0 fi -# Check each show against tracking file -NEW_SHOWS="" -while IFS= read -r show; do - if [ -n "$show" ] && ! grep -qF "$show" "$TRACKING_FILE"; then - NEW_SHOWS="$NEW_SHOWS$show\n" - echo "$(date): Found new show: $show" >>"$LOG_FILE" +# Cleanup temp files on exit +TMPFILE=$(mktemp /tmp/sonarr-history.XXXXXX.json) +trap 'rm -f "$TMPFILE"' EXIT + +# Fetch all grabbed history from Sonarr, paginating to avoid missing records +log "Querying Sonarr for grabbed episodes..." +ALL_RECORDS="" +PAGE=1 +PAGE_SIZE=250 +MAX_PAGES=20 +while true; do + if [ "$PAGE" -gt "$MAX_PAGES" ]; then + log "WARNING: Hit max page limit ($MAX_PAGES), some history may be missed" + break + fi + HTTP_CODE=$(curl -s --connect-timeout 10 --max-time 30 \ + -o "$TMPFILE" -w "%{http_code}" \ + "$SONARR_URL/api/v3/history?pageSize=$PAGE_SIZE&page=$PAGE&sortKey=date&sortDirection=descending&includeSeries=true" \ + -H "X-Api-Key: $SONARR_API_KEY") + + if [ "$HTTP_CODE" != "200" ]; then + log "ERROR: Sonarr API returned HTTP $HTTP_CODE (page $PAGE)" + exit 1 + fi + + # Validate JSON structure + if ! jq -e '.records' "$TMPFILE" >/dev/null 2>&1; then + log "ERROR: Sonarr API returned invalid JSON or missing records field (page $PAGE)" + exit 1 + fi + + # Parse grabbed records from this page + # Filter to grabbed events with valid sourceTitle and series.path + # Handle trailing slashes on series.path, skip null paths + PAGE_RECORDS=$(jq -r ' + .records[] + | select(.eventType == "grabbed") + | select(.sourceTitle != null and .sourceTitle != "") + | select(.series.path != null and .series.path != "") + | (.series.path | rtrimstr("/") | split("/") | last) as $folder + | select($folder != "") + | "\(.sourceTitle)\t\($folder)" + ' "$TMPFILE") || true + + if [ -n "$PAGE_RECORDS" ]; then + if [ -n "$ALL_RECORDS" ]; then + ALL_RECORDS="$ALL_RECORDS"$'\n'"$PAGE_RECORDS" + else + ALL_RECORDS="$PAGE_RECORDS" + fi + fi + + # Check if there are more pages + TOTAL_RECORDS=$(jq -r '.totalRecords // 0' "$TMPFILE") + if ! [[ "$TOTAL_RECORDS" =~ ^[0-9]+$ ]]; then + log "WARNING: Invalid totalRecords value from Sonarr: $TOTAL_RECORDS" + break + fi + FETCHED=$((PAGE * PAGE_SIZE)) + if [ "$FETCHED" -ge "$TOTAL_RECORDS" ]; then + break fi -done <<<"$REMOTE_SHOWS" + PAGE=$((PAGE + 1)) +done + +# Deduplicate by sourceTitle (keep first occurrence = most recent, preserving input order) +RECORDS=$(echo "$ALL_RECORDS" | awk -F'\t' '!seen[$1]++') + +if [ -z "$RECORDS" ]; then + log "No grabbed episodes found in Sonarr history" + exit 0 +fi + +# Get listing of what actually exists on the remote seedbox +log "Listing remote seedbox contents..." +RSYNC_OUTPUT=$(rsync -s --dry-run --list-only --timeout=30 "$REMOTE_HOST:$REMOTE_PATH" 2>&1) || { + log "ERROR: Failed to list remote seedbox: $RSYNC_OUTPUT" + exit 1 +} + +REMOTE_LISTING=$(echo "$RSYNC_OUTPUT" | awk '{ + type = substr($1, 1, 1); + $1=$2=$3=$4=""; + sub(/^ +/, ""); + if ($0 != "." && $0 != "") print type "\t" $0 +}') -if [ -z "$NEW_SHOWS" ]; then - echo "$(date): No new shows to download" >>"$LOG_FILE" +if [ -z "$REMOTE_LISTING" ]; then + log "No files found on remote seedbox" exit 0 fi -# Download new shows only -echo "$(date): Starting download of new shows..." >>"$LOG_FILE" -while IFS= read -r show; do - if [ -n "$show" ]; then - echo "$(date): Downloading $show" >>"$LOG_FILE" - # Set umask to allow group read/write access for Jellyfin - umask 002 - if rsync -r --log-file="$LOG_FILE" "$REMOTE_HOST:$REMOTE_PATH$show/" "$LOCAL_PATH/$show/"; then - echo "$show" >>"$TRACKING_FILE" - echo "$(date): Successfully downloaded $show" >>"$LOG_FILE" +# Write remote listing to temp file for awk matching (avoids passing sourceTitle through -v) +REMOTE_LISTING_FILE=$(mktemp /tmp/remote-listing.XXXXXX) +trap 'rm -f "$TMPFILE" "$REMOTE_LISTING_FILE"' EXIT +echo "$REMOTE_LISTING" >"$REMOTE_LISTING_FILE" + +# Process each grabbed episode from Sonarr +DOWNLOADED=0 +SKIPPED=0 +while IFS=$'\t' read -r source_title series_folder; do + [ -z "$source_title" ] && continue + [ -z "$series_folder" ] && continue + + # Skip if already downloaded (exact whole-line match) + if grep -qxF "$source_title" "$TRACKING_FILE"; then + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # Check if it exists on the remote seedbox (exact match on entry name) + # Use ENVIRON to pass source_title to awk, avoiding backslash interpretation from -v + REMOTE_MATCH=$(SOURCE_TITLE="$source_title" awk -F'\t' 'BEGIN { name = ENVIRON["SOURCE_TITLE"] } $2 == name { print; exit }' "$REMOTE_LISTING_FILE") || true + if [ -z "$REMOTE_MATCH" ]; then + log "Not yet on seedbox (still downloading?): $source_title" + continue + fi + + # Determine if it's a directory or file from the listing + ENTRY_TYPE=$(echo "$REMOTE_MATCH" | cut -f1) + + log "Downloading '$source_title' -> '$series_folder/'" + mkdir -p "$LOCAL_PATH/$series_folder" + + if [ "$ENTRY_TYPE" = "d" ]; then + # Directory: rsync recursively, trailing slash to put contents into series folder + if rsync -rs --partial --timeout=600 --log-file="$LOG_FILE" \ + "$REMOTE_HOST:$REMOTE_PATH$source_title/" \ + "$LOCAL_PATH/$series_folder/"; then + echo "$source_title" >>"$TRACKING_FILE" + log "Successfully downloaded directory: $source_title" + DOWNLOADED=$((DOWNLOADED + 1)) else - echo "$(date): Failed to download $show" >>"$LOG_FILE" + log "Failed to download directory: $source_title" + fi + else + # Single file: rsync into series folder + if rsync -s --partial --timeout=600 --log-file="$LOG_FILE" \ + "$REMOTE_HOST:$REMOTE_PATH$source_title" \ + "$LOCAL_PATH/$series_folder/"; then + echo "$source_title" >>"$TRACKING_FILE" + log "Successfully downloaded file: $source_title" + DOWNLOADED=$((DOWNLOADED + 1)) + else + log "Failed to download file: $source_title" fi fi -done <<<"$(echo -e "$NEW_SHOWS")" +done <<<"$RECORDS" + +log "Downloaded $DOWNLOADED new items ($SKIPPED already tracked)" # Trigger Jellyfin library scan -echo "$(date): Triggering Jellyfin library refresh..." >>"$LOG_FILE" -if curl -s -X POST "http://localhost:8096/Library/Refresh" \ - -H "X-Emby-Token: aef1b1e0cd5445dc97b755ef8c6224e5"; then - echo "$(date): Jellyfin library refresh triggered" >>"$LOG_FILE" -else - echo "$(date): Failed to trigger Jellyfin library refresh" >>"$LOG_FILE" +if [ "$DOWNLOADED" -gt 0 ]; then + log "Triggering Jellyfin library refresh..." + if curl -s --connect-timeout 10 --max-time 30 \ + -X POST "http://localhost:8096/Library/Refresh" \ + -H "X-Emby-Token: aef1b1e0cd5445dc97b755ef8c6224e5"; then + log "Jellyfin library refresh triggered" + else + log "Failed to trigger Jellyfin library refresh" + fi fi -echo "$(date): TV sync completed" >>"$LOG_FILE" +log "TV sync completed"