Skip to content
Closed
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ Works with any OpenAI-compatible API: local servers (llama.cpp, Ollama, vLLM, LM
## Features

- Animated Sorting Hat with drop animation, blinking eyes, and streaming thought bubble
- Supports text files and images including JPEG, PNG, GIF, BMP, TIFF, WebP, and SVG (via vision/multimodal models)
- Auto-detects image files by extension
- Supports text files, images (JPEG, PNG, GIF, BMP, TIFF, WebP, SVG), and audio files (MP3, WAV, FLAC, OGG, AAC, M4A)
- Auto-detects image and audio files by magic bytes and extension
- Handles reasoning/thinking tokens from models like Qwen, DeepSeek, etc.
- Quiet mode for scripting (`--quiet` / `-q`)
- Configurable reasoning: guard clause defaults to no thinking, naming uses thinking. `--nothink` disables both, `--fullthink` enables both
Expand All @@ -62,6 +62,7 @@ Works with any OpenAI-compatible API: local servers (llama.cpp, Ollama, vLLM, LM
- An OpenAI-compatible LLM API endpoint
- For image naming: a vision-capable model (e.g., GPT-4o, LLaVA, Qwen-VL)
- Optional: `Pillow` (`pip install Pillow`) for EXIF metadata extraction from images
- Optional: `ffprobe` (from `ffmpeg`) for richer audio metadata (tags, duration, bitrate, codec)

## Installation

Expand Down Expand Up @@ -188,7 +189,7 @@ done

1. **Guard clause**: Asks the LLM whether the current filename is already descriptive. If yes, skips the file. If no, the check conversation becomes context for the naming request (two-turn multi-turn). Use `--force` to skip the check entirely.
2. **Metadata collection**: Gathers file metadata (size, modification date, MIME type, EXIF for images) to give the LLM more context. Use `--no-metadata` to skip.
3. **File analysis**: For text files, reads the first 4KB of content. For images, base64-encodes and sends via the OpenAI multimodal format.
3. **File analysis**: For text files, reads the first 4KB of content. For images, base64-encodes and sends via the OpenAI multimodal format. For audio files, extracts tags, duration, bitrate, and codec via `ffprobe` (if available) and passes them as text context — no audio bytes are sent to the LLM.
4. **LLM query**: Sends the content, metadata, and any user context (`--context`) to your configured LLM with a prompt asking for a descriptive kebab-case filename. When the guard clause ran first, this becomes a multi-turn conversation with richer context.
5. **Streaming display**: Shows the model's reasoning tokens in a speech bubble above the animated hat (supports both `reasoning_content` field and `<think>` tags).
6. **Name sanitization**: Cleans the response into a valid filename. When preserving extensions (default), the model only generates the name stem and the original extension is appended automatically.
Expand Down
127 changes: 127 additions & 0 deletions hat
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,58 @@ is_binary() {
[[ "$mime" == "binary" ]]
}

# Detect audio by magic bytes first, then fall back to extension
is_audio() {
local file="$1"
local magic
magic=$(head -c 12 "$file" 2>/dev/null | od -A n -t x1 -N 12 2>/dev/null | tr -d ' \n')
# MP3: ID3 tag (49 44 33) or MPEG sync (ff fb / ff fa / ff f3 / ff f2)
[[ "$magic" == 494433* ]] && return 0
[[ "$magic" == fffb* || "$magic" == fffa* || "$magic" == fff3* || "$magic" == fff2* ]] && return 0
# FLAC: 66 4c 61 43
[[ "$magic" == 664c6143* ]] && return 0
# OGG: 4f 67 67 53
[[ "$magic" == 4f676753* ]] && return 0
# AAC (ADTS): ff f1 / ff f9
[[ "$magic" == fff1* || "$magic" == fff9* ]] && return 0
# WAV: RIFF (52 49 46 46) + WAVE at offset 8 (57 41 56 45)
[[ "$magic" == 52494646* ]] && [[ "${magic:16:8}" == "57415645" ]] && return 0
# M4A / AAC in MP4: ftyp box — check bytes 4-7 for 66 74 79 70,
# but only if the ftyp brand is audio (M4A, M4B, mp42, etc.), not video
if [[ "${magic:8:8}" == "66747970" ]]; then
local brand="${magic:16:8}"
# M4A: 4d344120, M4B: 4d344220, M4P: 4d344220 — audio brands
[[ "$brand" == "4d344120" || "$brand" == "4d344220" || "$brand" == "4d345020" ]] && return 0
fi
# Fall back to extension
local ext="${file##*.}"
ext="${ext,,}"
[[ "$ext" =~ ^(mp3|wav|flac|ogg|aac|m4a|opus|wma|aiff|ape)$ ]]
}

# Detect video by magic bytes first, then fall back to extension
is_video() {
local file="$1"
local magic
magic=$(head -c 12 "$file" 2>/dev/null | od -A n -t x1 -N 12 2>/dev/null | tr -d ' \n')
# MKV / WebM: EBML header 1a 45 df a3
[[ "$magic" == 1a45dfa3* ]] && return 0
# AVI: RIFF (52 49 46 46) + AVI at offset 8 (41 56 49 20)
[[ "$magic" == 52494646* ]] && [[ "${magic:16:8}" == "41564920" ]] && return 0
# MP4 / MOV / M4V: ftyp box at bytes 4-7
if [[ "${magic:8:8}" == "66747970" ]]; then
local brand="${magic:16:8}"
# Exclude known audio-only brands (M4A, M4B, M4P)
[[ "$brand" != "4d344120" && "$brand" != "4d344220" && "$brand" != "4d345020" ]] && return 0
fi
# MPEG-1/2 video: 00 00 01 b3 (sequence header) or 00 00 01 ba (pack header)
[[ "$magic" == 000001b3* || "$magic" == 000001ba* ]] && return 0
# Fall back to extension
local ext="${file##*.}"
ext="${ext,,}"
[[ "$ext" =~ ^(mp4|mkv|avi|mov|webm|m4v|wmv|flv|mpeg|mpg|3gp|ogv|ts|mts|m2ts)$ ]]
}

# Detect image by magic bytes first, then fall back to extension
is_image() {
local file="$1"
Expand Down Expand Up @@ -124,6 +176,10 @@ if mode == "image":
{'type': 'image_url', 'image_url': {'url': f'data:{mime};base64,{b64}'}},
{'type': 'text', 'text': prompt}
]
elif mode in ("audio", "video"):
# Audio/video files can't be passed to the LLM directly — metadata was
# already injected into the prompt by collect_metadata().
content = prompt
else:
with open(file, 'r', errors='replace') as f:
content = f.read(4000)
Expand Down Expand Up @@ -246,6 +302,73 @@ if mode == 'image':
if ed: meta['exif'] = ed
except Exception:
pass
elif mode == 'audio':
import subprocess
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_format', '-show_streams', file],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
probe = json.loads(result.stdout)
fmt = probe.get('format', {})
tags = fmt.get('tags', {})
# Normalise tag keys to lowercase
tags = {k.lower(): v for k, v in tags.items()}
for key in ('title', 'artist', 'album', 'album_artist', 'date', 'genre', 'track', 'comment'):
if tags.get(key):
meta[key] = tags[key][:100]
if fmt.get('duration'):
secs = float(fmt['duration'])
meta['duration'] = f'{int(secs//60)}:{int(secs%60):02d}'
if fmt.get('bit_rate'):
meta['bitrate_kbps'] = str(int(fmt['bit_rate']) // 1000)
# Codec from first audio stream
for stream in probe.get('streams', []):
if stream.get('codec_type') == 'audio':
if stream.get('codec_name'):
meta['codec'] = stream['codec_name']
if stream.get('sample_rate'):
meta['sample_rate_hz'] = stream['sample_rate']
break
except Exception:
pass
elif mode == 'video':
import subprocess
try:
result = subprocess.run(
['ffprobe', '-v', 'quiet', '-print_format', 'json',
'-show_format', '-show_streams', file],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
probe = json.loads(result.stdout)
fmt = probe.get('format', {})
tags = {k.lower(): v for k, v in fmt.get('tags', {}).items()}
for key in ('title', 'comment', 'date', 'artist', 'album'):
if tags.get(key):
meta[key] = tags[key][:100]
if fmt.get('duration'):
secs = float(fmt['duration'])
meta['duration'] = f'{int(secs//60)}:{int(secs%60):02d}'
if fmt.get('bit_rate'):
meta['bitrate_kbps'] = str(int(fmt['bit_rate']) // 1000)
for stream in probe.get('streams', []):
if stream.get('codec_type') == 'video':
if stream.get('codec_name'):
meta['video_codec'] = stream['codec_name']
if stream.get('width') and stream.get('height'):
meta['resolution'] = str(stream['width']) + 'x' + str(stream['height'])
if stream.get('avg_frame_rate'):
fr = stream['avg_frame_rate']
if '/' in fr:
n, d = fr.split('/')
if int(d) > 0:
meta['fps'] = str(round(int(n) / int(d), 2))
break
except Exception:
pass
print(json.dumps(meta, ensure_ascii=False))
" "$file" "$mode"
}
Expand Down Expand Up @@ -734,6 +857,10 @@ process_file() {
local mode="text"
if [[ "$force_image" == "true" ]] || is_image "$file"; then
mode="image"
elif is_audio "$file"; then
mode="audio"
elif is_video "$file"; then
mode="video"
fi

if [[ "$mode" == "text" ]] && is_binary "$file"; then
Expand Down
Binary file added test-assets/sample.mkv
Binary file not shown.
Binary file added test-assets/sample.mp4
Binary file not shown.
Binary file added test-assets/sample.webm
Binary file not shown.
125 changes: 124 additions & 1 deletion test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ setup_file() {
# Source testable bash functions
eval "$(sed -n '/^sanitize_name/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_binary/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_audio/,/^}/p' "$HAT")"
eval "$(sed -n '/^is_video/,/^}/p' "$HAT")"
eval "$(sed -n '/^collect_metadata/,/^}/p' "$HAT")"
export -f sanitize_name is_binary collect_metadata
export -f sanitize_name is_binary is_audio is_video collect_metadata

# Start mock LLM server that handles multi-turn conversations
export MOCK_PORT=18950
Expand Down Expand Up @@ -223,3 +225,124 @@ teardown_file() {
assert_output --partial "could not reach LLM"
refute_output --partial "Traceback"
}

# ── Audio detection ──────────────────────────────────────────────────

@test "audio: mp3 is detected as audio" {
run is_audio "$TEST_ASSETS/sample.mp3"
assert_success
}

@test "audio: wav is detected as audio" {
run is_audio "$TEST_ASSETS/sample.wav"
assert_success
}

@test "audio: flac is detected as audio" {
run is_audio "$TEST_ASSETS/sample.flac"
assert_success
}

@test "audio: ogg is detected as audio" {
run is_audio "$TEST_ASSETS/sample.ogg"
assert_success
}

@test "audio: aac is detected as audio" {
run is_audio "$TEST_ASSETS/sample.aac"
assert_success
}

@test "audio: m4a is detected as audio" {
run is_audio "$TEST_ASSETS/sample.m4a"
assert_success
}

@test "audio: text file is not audio" {
run is_audio "$TEST_ASSETS/sample.txt"
assert_failure
}

@test "audio: image file is not audio" {
run is_audio "$TEST_ASSETS/sample.jpg"
assert_failure
}

# ── Audio metadata ───────────────────────────────────────────────────

@test "audio metadata: mp3 has size and modified" {
run collect_metadata "$TEST_ASSETS/sample.mp3" "audio"
assert_output --partial "size_bytes"
assert_output --partial "modified"
}

@test "audio metadata: mp3 has mime_type" {
run collect_metadata "$TEST_ASSETS/sample.mp3" "audio"
assert_output --partial "mime_type"
}

# ── Audio integration ────────────────────────────────────────────────

@test "audio: mp3 is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mp3' 2>/dev/null"
assert_output "suggested-name.mp3"
}

@test "audio: flac is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.flac' 2>/dev/null"
assert_output "suggested-name.flac"
}

@test "audio: wav is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.wav' 2>/dev/null"
assert_output "suggested-name.wav"
}

# ── Video detection ──────────────────────────────────────────────────

@test "video: mp4 is detected as video" {
run is_video "$TEST_ASSETS/sample.mp4"
assert_success
}

@test "video: webm is detected as video" {
run is_video "$TEST_ASSETS/sample.webm"
assert_success
}

@test "video: mkv is detected as video" {
run is_video "$TEST_ASSETS/sample.mkv"
assert_success
}

@test "video: text file is not video" {
run is_video "$TEST_ASSETS/sample.txt"
assert_failure
}

@test "video: audio file is not video" {
run is_video "$TEST_ASSETS/sample.mp3"
assert_failure
}

@test "video: m4a is not detected as video" {
run is_video "$TEST_ASSETS/sample.m4a"
assert_failure
}

# ── Video integration ────────────────────────────────────────────────

@test "video: mp4 is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mp4' 2>/dev/null"
assert_output "suggested-name.mp4"
}

@test "video: webm is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.webm' 2>/dev/null"
assert_output "suggested-name.webm"
}

@test "video: mkv is processed (not skipped as binary)" {
run bash -c "LLM_BASE_URL=http://127.0.0.1:$MOCK_PORT bash '$HAT' --quiet --dry-run --force '$TEST_ASSETS/sample.mkv' 2>/dev/null"
assert_output "suggested-name.mkv"
}