diff --git a/.env.example b/.env.example index 761220c..735dbbe 100644 --- a/.env.example +++ b/.env.example @@ -54,6 +54,24 @@ STASHCAST_USER_TOKEN=608AF9E5-E989-4729-9C05-7FFB6EA86FE4 # Optional: Auto-select first match for Spotify URLs (no user input needed) # STASHCAST_ACCEPT_FIRST_MATCH=true +# Optional: Speech-to-text transcription (offline, uses faster-whisper) +# Set a model name to enable STT for media without subtitles. +# Models: tiny (~75MB), base (~150MB), small (~500MB), medium (~1.5GB), large-v3 (~3GB) +# Leave commented/empty to disable. +# STASHCAST_STT_MODEL=base + +# Optional: Language for STT (default: same as LANGUAGE_CODE) +# Set to 'auto' for auto-detection, or an ISO code like 'en', 'es', 'pt' +# STASHCAST_STT_LANGUAGE=auto + +# Optional: Device for STT inference +# 'auto' (detect GPU), 'cpu', 'cuda' +# STASHCAST_STT_DEVICE=auto + +# Optional: Compute type for STT inference +# 'auto', 'int8' (CPU-friendly), 'float16' (GPU), 'float32' +# STASHCAST_STT_COMPUTE_TYPE=auto + # Optional: Maximum number of episodes to keep (0 = unlimited) # When the limit is reached, new downloads are blocked until episodes are deleted # STASHCAST_MAX_EPISODES=50 diff --git a/media/management/commands/transcribe.py b/media/management/commands/transcribe.py new file mode 100644 index 0000000..7708f62 --- /dev/null +++ b/media/management/commands/transcribe.py @@ -0,0 +1,105 @@ +""" +Django management command to transcribe media files to VTT using faster-whisper. + +Usage: + ./manage.py transcribe /path/to/audio.mp3 + ./manage.py transcribe /path/to/video.mp4 --model large-v3 --language es + ./manage.py transcribe /path/to/audio.m4a --output /tmp/subtitles.vtt +""" + +from pathlib import Path + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError + + +class Command(BaseCommand): + help = 'Transcribe an audio/video file to VTT using faster-whisper (offline STT)' + + def add_arguments(self, parser): + parser.add_argument('source', type=str, help='Path to audio or video file') + parser.add_argument( + '--output', + '-o', + type=str, + default=None, + help='Output VTT file path (default: .vtt)', + ) + parser.add_argument( + '--model', + type=str, + default=settings.STASHCAST_STT_MODEL or 'base', + help='Whisper model size: tiny, base, small, medium, large-v3 ' + f'(default: {settings.STASHCAST_STT_MODEL or "base"})', + ) + parser.add_argument( + '--language', + type=str, + default=None, + help='Language code (e.g. en, es, pt) or omit for auto-detect', + ) + parser.add_argument( + '--device', + type=str, + default=settings.STASHCAST_STT_DEVICE, + help=f'Device: auto, cpu, cuda (default: {settings.STASHCAST_STT_DEVICE})', + ) + parser.add_argument( + '--compute-type', + type=str, + default=settings.STASHCAST_STT_COMPUTE_TYPE, + help=f'Compute type: auto, int8, float16, float32 ' + f'(default: {settings.STASHCAST_STT_COMPUTE_TYPE})', + ) + + def handle(self, *args, **options): + source = Path(options['source']) + if not source.exists(): + raise CommandError(f'File not found: {source}') + if not source.is_file(): + raise CommandError(f'Not a file: {source}') + + output = options['output'] + if output: + output_path = Path(output) + else: + output_path = source.with_suffix('.vtt') + + model = options['model'] + language = options['language'] + device = options['device'] + compute_type = options['compute_type'] + + self.stdout.write(f'Source: {source}') + self.stdout.write(f'Output: {output_path}') + self.stdout.write(f'Model: {model}') + self.stdout.write(f'Language: {language or "auto-detect"}') + self.stdout.write(f'Device: {device}') + self.stdout.write(f'Compute: {compute_type}') + self.stdout.write('') + + try: + from media.service.transcribe import transcribe + + result = transcribe( + media_path=source, + output_path=output_path, + model_size=model, + language=language, + device=device, + compute_type=compute_type, + logger=lambda m: self.stdout.write(m), + ) + + self.stdout.write('') + self.stdout.write(self.style.SUCCESS('Transcription complete')) + self.stdout.write(self.style.SUCCESS(f'Language: {result.language}')) + self.stdout.write(self.style.SUCCESS(f'Time: {result.duration_seconds:.1f}s')) + self.stdout.write(self.style.SUCCESS(f'Output: {result.vtt_path}')) + + except ImportError: + raise CommandError( + 'faster-whisper is not installed. Install it with:\n pip install faster-whisper' + ) + except Exception as e: + raise CommandError(f'Transcription failed: {e}') diff --git a/media/service/transcribe.py b/media/service/transcribe.py new file mode 100644 index 0000000..0a0ce5e --- /dev/null +++ b/media/service/transcribe.py @@ -0,0 +1,195 @@ +""" +Speech-to-text transcription service using faster-whisper. + +Transcribes audio/video files to VTT subtitle format for media items +that don't already have subtitles. Runs entirely offline. + +The model is loaded per-transcription and explicitly unloaded after, +so memory (potentially 10GB for large-v3) is freed between runs. +""" + +import gc +import time +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class TranscriptionResult: + """Result of a transcription operation.""" + + vtt_path: Path + language: str + duration_seconds: float + + +def transcribe( + media_path, + output_path, + model_size='base', + language=None, + device='auto', + compute_type='auto', + logger=None, +): + """ + Transcribe an audio/video file to VTT format using faster-whisper. + + The model is loaded, used, and then explicitly freed so that memory + is not held between transcription jobs. + + Args: + media_path: Path to the audio or video file. + output_path: Path where the VTT file will be written. + model_size: Whisper model size (tiny, base, small, medium, large-v3). + language: ISO language code (e.g. 'en', 'es', 'pt') or None for auto-detect. + device: 'auto', 'cpu', or 'cuda'. + compute_type: 'auto', 'int8', 'float16', or 'float32'. + logger: Optional callable(str) for logging. + + Returns: + TranscriptionResult with the VTT path, detected language, and elapsed time. + """ + + def log(message): + if logger: + logger(message) + + media_path = Path(media_path) + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + + if not media_path.exists(): + raise FileNotFoundError(f'Media file not found: {media_path}') + + log(f'Transcribing: {media_path.name}') + log(f'Model: {model_size}, language: {language or "auto-detect"}, device: {device}') + + start_time = time.monotonic() + model = None + + try: + from faster_whisper import WhisperModel + + # Resolve device/compute_type defaults + resolved_device = device + resolved_compute = compute_type + if device == 'auto': + resolved_device, resolved_compute = _pick_device_and_compute(compute_type) + + log(f'Loading model (device={resolved_device}, compute={resolved_compute})...') + model_load_start = time.monotonic() + + model = WhisperModel( + model_size, + device=resolved_device, + compute_type=resolved_compute, + ) + + model_load_elapsed = time.monotonic() - model_load_start + log(f'Model loaded in {model_load_elapsed:.1f}s') + + # Transcribe + transcribe_start = time.monotonic() + segments, info = model.transcribe( + str(media_path), + language=language, + vad_filter=True, + word_timestamps=False, + ) + + detected_language = info.language + log(f'Detected language: {detected_language} (probability {info.language_probability:.2f})') + + # Write VTT + _write_vtt(segments, output_path, log) + + transcribe_elapsed = time.monotonic() - transcribe_start + total_elapsed = time.monotonic() - start_time + log( + f'Transcription completed in {transcribe_elapsed:.1f}s (total with model load: {total_elapsed:.1f}s)' + ) + + return TranscriptionResult( + vtt_path=output_path, + language=detected_language, + duration_seconds=total_elapsed, + ) + + finally: + # Explicitly free model memory + del model + gc.collect() + + try: + import torch + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + except ImportError: + pass + + +def _pick_device_and_compute(compute_type): + """ + Auto-detect the best device and compute type. + + Returns: + (device, compute_type) tuple + """ + try: + import torch + + if torch.cuda.is_available(): + if compute_type == 'auto': + return 'cuda', 'float16' + return 'cuda', compute_type + except ImportError: + pass + + if compute_type == 'auto': + return 'cpu', 'int8' + return 'cpu', compute_type + + +def _write_vtt(segments, output_path, log): + """ + Write transcription segments to a VTT file. + + Args: + segments: Iterator of faster-whisper Segment objects. + output_path: Path for the output VTT file. + log: Logging callable. + """ + segment_count = 0 + + with open(output_path, 'w', encoding='utf-8') as f: + f.write('WEBVTT\n\n') + + for segment in segments: + segment_count += 1 + start = _format_timestamp(segment.start) + end = _format_timestamp(segment.end) + text = segment.text.strip() + + if text: + f.write(f'{start} --> {end}\n') + f.write(f'{text}\n\n') + + log(f'Wrote {segment_count} segments to {output_path.name}') + + +def _format_timestamp(seconds): + """ + Format seconds as VTT timestamp (HH:MM:SS.mmm). + + Args: + seconds: Time in seconds (float). + + Returns: + str: Formatted timestamp. + """ + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = seconds % 60 + return f'{hours:02d}:{minutes:02d}:{secs:06.3f}' diff --git a/media/tasks.py b/media/tasks.py index 22bd6d2..17e2b57 100644 --- a/media/tasks.py +++ b/media/tasks.py @@ -183,8 +183,12 @@ def process_media(guid): clear_progress(item.guid) - # Generate summary if subtitles are available - if item.subtitle_path and settings.STASHCAST_SUMMARY_SENTENCES > 0: + # Transcribe if no subtitles and STT is enabled + if not item.subtitle_path and settings.STASHCAST_STT_MODEL: + write_log(log_path, 'No subtitles available, enqueuing transcription task') + transcribe_media(item.guid) + elif item.subtitle_path and settings.STASHCAST_SUMMARY_SENTENCES > 0: + # Generate summary if subtitles are already available write_log(log_path, 'Enqueuing summary generation task') generate_summary(item.guid) @@ -208,6 +212,75 @@ def process_media(guid): raise +@db_task() +def transcribe_media(guid): + """ + Transcribe media to VTT using faster-whisper when no subtitles exist. + + Loads the model, transcribes, frees memory, then chains to summary generation. + Timing is logged so operators can gauge cost per item. + """ + if not settings.STASHCAST_STT_MODEL: + return + + try: + item = MediaItem.objects.get(guid=guid) + except MediaItem.DoesNotExist: + return + + # Skip if subtitles already exist + if item.subtitle_path: + return + + content_path = item.get_absolute_content_path() + if not content_path or not os.path.exists(content_path): + return + + log_path = item.get_absolute_log_path() if item.log_path else None + base_dir = item.get_base_dir() + if not base_dir: + return + + vtt_output = base_dir / 'subtitles.vtt' + + try: + if log_path: + write_log(log_path, '=== TRANSCRIBING (speech-to-text) ===') + write_log(log_path, f'Model: {settings.STASHCAST_STT_MODEL}') + write_log(log_path, f'Language: {settings.STASHCAST_STT_LANGUAGE or "auto-detect"}') + + from media.service.transcribe import transcribe + + result = transcribe( + media_path=content_path, + output_path=vtt_output, + model_size=settings.STASHCAST_STT_MODEL, + language=settings.STASHCAST_STT_LANGUAGE, + device=settings.STASHCAST_STT_DEVICE, + compute_type=settings.STASHCAST_STT_COMPUTE_TYPE, + logger=lambda m: write_log(log_path, m) if log_path else None, + ) + + item.subtitle_path = 'subtitles.vtt' + item.save() + + if log_path: + write_log( + log_path, + f'Transcription complete: language={result.language}, ' + f'took {result.duration_seconds:.1f}s', + ) + + # Now chain to summary generation + if settings.STASHCAST_SUMMARY_SENTENCES > 0: + generate_summary(item.guid) + + except Exception as e: + if log_path: + write_log(log_path, f'Transcription failed: {str(e)}') + # Don't fail the whole item — it's already READY, just without a transcript + + @db_task() def generate_summary(guid): """ @@ -592,7 +665,9 @@ def process_media_batch(guids: List[str]): clear_progress(guid) - if item.subtitle_path and settings.STASHCAST_SUMMARY_SENTENCES > 0: + if not item.subtitle_path and settings.STASHCAST_STT_MODEL: + transcribe_media(item.guid) + elif item.subtitle_path and settings.STASHCAST_SUMMARY_SENTENCES > 0: generate_summary(item.guid) write_log(batch_log_path, f'Completed: {item.title}') diff --git a/media/test_service/test_transcribe.py b/media/test_service/test_transcribe.py new file mode 100644 index 0000000..fc6820b --- /dev/null +++ b/media/test_service/test_transcribe.py @@ -0,0 +1,309 @@ +""" +Tests for service/transcribe.py +""" + +import tempfile +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from django.test import TestCase + +from media.service.transcribe import ( + TranscriptionResult, + _format_timestamp, + _pick_device_and_compute, + _write_vtt, + transcribe, +) + + +class TranscriptionResultTest(TestCase): + """Tests for TranscriptionResult dataclass""" + + def test_dataclass_fields(self): + result = TranscriptionResult( + vtt_path=Path('/tmp/subtitles.vtt'), + language='en', + duration_seconds=12.5, + ) + self.assertEqual(result.vtt_path, Path('/tmp/subtitles.vtt')) + self.assertEqual(result.language, 'en') + self.assertEqual(result.duration_seconds, 12.5) + + +class FormatTimestampTest(TestCase): + """Tests for VTT timestamp formatting""" + + def test_zero(self): + self.assertEqual(_format_timestamp(0), '00:00:00.000') + + def test_seconds_only(self): + self.assertEqual(_format_timestamp(5.123), '00:00:05.123') + + def test_minutes_and_seconds(self): + self.assertEqual(_format_timestamp(65.5), '00:01:05.500') + + def test_hours(self): + self.assertEqual(_format_timestamp(3661.0), '01:01:01.000') + + def test_fractional_milliseconds(self): + result = _format_timestamp(1.1) + self.assertEqual(result, '00:00:01.100') + + +class PickDeviceAndComputeTest(TestCase): + """Tests for device/compute auto-detection""" + + def test_cpu_fallback_when_no_torch(self): + """Falls back to CPU/int8 when torch is not available""" + mock_torch = MagicMock() + mock_torch.cuda.is_available.return_value = False + with patch.dict('sys.modules', {'torch': mock_torch}): + device, compute = _pick_device_and_compute('auto') + self.assertEqual(device, 'cpu') + self.assertEqual(compute, 'int8') + + def test_explicit_compute_type_on_cpu(self): + """Explicit compute type is respected on CPU""" + mock_torch = MagicMock() + mock_torch.cuda.is_available.return_value = False + with patch.dict('sys.modules', {'torch': mock_torch}): + device, compute = _pick_device_and_compute('float32') + self.assertEqual(device, 'cpu') + self.assertEqual(compute, 'float32') + + def test_cuda_when_available(self): + """Uses CUDA with float16 when GPU is available""" + mock_torch = MagicMock() + mock_torch.cuda.is_available.return_value = True + with patch.dict('sys.modules', {'torch': mock_torch}): + device, compute = _pick_device_and_compute('auto') + self.assertEqual(device, 'cuda') + self.assertEqual(compute, 'float16') + + def test_cuda_explicit_compute(self): + """Explicit compute type is respected on CUDA""" + mock_torch = MagicMock() + mock_torch.cuda.is_available.return_value = True + with patch.dict('sys.modules', {'torch': mock_torch}): + device, compute = _pick_device_and_compute('int8') + self.assertEqual(device, 'cuda') + self.assertEqual(compute, 'int8') + + +class WriteVttTest(TestCase): + """Tests for VTT file writing""" + + def test_writes_valid_vtt(self): + """Produces a valid VTT file with header and segments""" + segments = [ + SimpleNamespace(start=0.0, end=2.5, text=' Hello world '), + SimpleNamespace(start=3.0, end=5.0, text=' Second line '), + ] + log_messages = [] + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / 'subtitles.vtt' + _write_vtt(iter(segments), output, lambda m: log_messages.append(m)) + + content = output.read_text() + + self.assertTrue(content.startswith('WEBVTT\n\n')) + self.assertIn('00:00:00.000 --> 00:00:02.500', content) + self.assertIn('Hello world', content) + self.assertIn('00:00:03.000 --> 00:00:05.000', content) + self.assertIn('Second line', content) + self.assertEqual(len(log_messages), 1) + self.assertIn('2 segments', log_messages[0]) + + def test_skips_empty_text(self): + """Segments with blank text are skipped""" + segments = [ + SimpleNamespace(start=0.0, end=1.0, text=' '), + SimpleNamespace(start=1.0, end=2.0, text=' Real text '), + ] + + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / 'subtitles.vtt' + _write_vtt(iter(segments), output, lambda m: None) + + content = output.read_text() + self.assertNotIn('00:00:00.000 --> 00:00:01.000', content) + self.assertIn('Real text', content) + + def test_empty_segments(self): + """No segments produces a VTT header only""" + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / 'subtitles.vtt' + _write_vtt(iter([]), output, lambda m: None) + + content = output.read_text() + self.assertEqual(content, 'WEBVTT\n\n') + + +class TranscribeTest(TestCase): + """Tests for the main transcribe function""" + + def test_file_not_found_raises(self): + """Raises FileNotFoundError for nonexistent media""" + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(FileNotFoundError): + transcribe( + media_path=Path(tmp) / 'nonexistent.mp3', + output_path=Path(tmp) / 'out.vtt', + ) + + @patch('faster_whisper.WhisperModel') + def test_transcribe_success(self, mock_whisper_class): + """End-to-end test with mocked WhisperModel""" + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [ + SimpleNamespace(start=0.0, end=2.0, text='Hello world'), + SimpleNamespace(start=2.5, end=4.0, text='Testing speech'), + ] + info = SimpleNamespace(language='en', language_probability=0.98) + mock_model.transcribe.return_value = (iter(segments), info) + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + media_file = tmp / 'audio.mp3' + media_file.write_bytes(b'fake audio data') + output_file = tmp / 'subtitles.vtt' + + log_messages = [] + result = transcribe( + media_path=media_file, + output_path=output_file, + model_size='base', + language='en', + device='cpu', + compute_type='int8', + logger=lambda m: log_messages.append(m), + ) + + self.assertEqual(result.vtt_path, output_file) + self.assertEqual(result.language, 'en') + self.assertGreater(result.duration_seconds, 0) + + self.assertTrue(output_file.exists()) + content = output_file.read_text() + self.assertIn('WEBVTT', content) + self.assertIn('Hello world', content) + + mock_whisper_class.assert_called_once_with( + 'base', + device='cpu', + compute_type='int8', + ) + + mock_model.transcribe.assert_called_once_with( + str(media_file), + language='en', + vad_filter=True, + word_timestamps=False, + ) + + self.assertTrue(any('Transcribing' in m for m in log_messages)) + self.assertTrue(any('Detected language' in m for m in log_messages)) + self.assertTrue(any('completed' in m for m in log_messages)) + + @patch('faster_whisper.WhisperModel') + def test_transcribe_auto_language(self, mock_whisper_class): + """Language=None triggers auto-detection""" + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Hola')] + info = SimpleNamespace(language='es', language_probability=0.95) + mock_model.transcribe.return_value = (iter(segments), info) + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + media_file = tmp / 'audio.mp3' + media_file.write_bytes(b'fake') + + result = transcribe( + media_path=media_file, + output_path=tmp / 'out.vtt', + language=None, + device='cpu', + compute_type='int8', + ) + + self.assertEqual(result.language, 'es') + mock_model.transcribe.assert_called_once() + call_kwargs = mock_model.transcribe.call_args[1] + self.assertIsNone(call_kwargs['language']) + + @patch('faster_whisper.WhisperModel') + def test_model_is_freed_after_transcription(self, mock_whisper_class): + """Model reference is deleted after transcription (memory management)""" + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Test')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + media_file = tmp / 'audio.mp3' + media_file.write_bytes(b'fake') + + result = transcribe( + media_path=media_file, + output_path=tmp / 'out.vtt', + device='cpu', + compute_type='int8', + ) + + self.assertIsNotNone(result) + + @patch('faster_whisper.WhisperModel') + def test_model_freed_on_error(self, mock_whisper_class): + """Model is freed even when transcription fails""" + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + mock_model.transcribe.side_effect = RuntimeError('out of memory') + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + media_file = tmp / 'audio.mp3' + media_file.write_bytes(b'fake') + + with self.assertRaises(RuntimeError): + transcribe( + media_path=media_file, + output_path=tmp / 'out.vtt', + device='cpu', + compute_type='int8', + ) + + @patch('faster_whisper.WhisperModel') + def test_output_directory_created(self, mock_whisper_class): + """Output directory is created if it doesn't exist""" + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Test')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + with tempfile.TemporaryDirectory() as tmp: + tmp = Path(tmp) + media_file = tmp / 'audio.mp3' + media_file.write_bytes(b'fake') + + output_file = tmp / 'nested' / 'dir' / 'subtitles.vtt' + + transcribe( + media_path=media_file, + output_path=output_file, + device='cpu', + compute_type='int8', + ) + + self.assertTrue(output_file.exists()) diff --git a/media/tests/test_unit.py b/media/tests/test_unit.py index f970013..bf7500e 100644 --- a/media/tests/test_unit.py +++ b/media/tests/test_unit.py @@ -1620,3 +1620,281 @@ def test_all_batch_settings_exist(self): self.assertTrue(hasattr(settings, 'STASHCAST_DEFAULT_YTDLP_ARGS_AUDIO')) self.assertTrue(hasattr(settings, 'STASHCAST_DEFAULT_YTDLP_ARGS_VIDEO')) self.assertTrue(hasattr(settings, 'STASHCAST_SUMMARY_SENTENCES')) + + +class TranscribeMediaTaskTest(TestCase): + """Tests for the transcribe_media Huey task""" + + def test_stt_settings_exist(self): + """Test that all STT settings exist""" + self.assertTrue(hasattr(settings, 'STASHCAST_STT_MODEL')) + self.assertTrue(hasattr(settings, 'STASHCAST_STT_LANGUAGE')) + self.assertTrue(hasattr(settings, 'STASHCAST_STT_DEVICE')) + self.assertTrue(hasattr(settings, 'STASHCAST_STT_COMPUTE_TYPE')) + + @override_settings(STASHCAST_STT_MODEL='') + def test_transcribe_skipped_when_disabled(self): + """Transcription is skipped when STASHCAST_STT_MODEL is empty""" + from media.tasks import transcribe_media + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-no-stt', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + ) + + # Should return immediately without touching the item + transcribe_media.call_local(item.guid) + + item.refresh_from_db() + self.assertEqual(item.subtitle_path, '') + + @override_settings(STASHCAST_STT_MODEL='base') + def test_transcribe_skipped_when_subtitles_exist(self): + """Transcription is skipped when subtitles are already present""" + from media.tasks import transcribe_media + + item = MediaItem.objects.create( + source_url='https://example.com/video.mp4', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-has-subs', + media_type=MediaItem.MEDIA_TYPE_VIDEO, + status=MediaItem.STATUS_READY, + subtitle_path='subtitles.vtt', + ) + + # Should return immediately — subtitles already exist + transcribe_media.call_local(item.guid) + + item.refresh_from_db() + self.assertEqual(item.subtitle_path, 'subtitles.vtt') + + @override_settings(STASHCAST_STT_MODEL='base') + def test_transcribe_skipped_when_no_content(self): + """Transcription is skipped when content file doesn't exist""" + from media.tasks import transcribe_media + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-no-content', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='', + ) + + transcribe_media.call_local(item.guid) + + item.refresh_from_db() + self.assertEqual(item.subtitle_path, '') + + def test_transcribe_nonexistent_guid(self): + """Transcription handles nonexistent GUID gracefully""" + from media.tasks import transcribe_media + + # Should return without error + transcribe_media.call_local('nonexistent-guid-12345') + + @override_settings( + STASHCAST_STT_MODEL='base', + STASHCAST_STT_LANGUAGE='en', + STASHCAST_STT_DEVICE='cpu', + STASHCAST_STT_COMPUTE_TYPE='int8', + STASHCAST_SUMMARY_SENTENCES=0, + ) + @patch('faster_whisper.WhisperModel') + def test_transcribe_success_updates_subtitle_path(self, mock_whisper_class): + """Successful transcription sets subtitle_path on the item""" + from media.tasks import transcribe_media + + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=2.0, text='Hello world')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-transcribe-ok', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='content.m4a', + log_path='download.log', + ) + + # Create the content file so the task doesn't skip + base_dir = item.get_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'content.m4a').write_bytes(b'fake audio') + + transcribe_media.call_local(item.guid) + + item.refresh_from_db() + self.assertEqual(item.subtitle_path, 'subtitles.vtt') + + # VTT file should exist + vtt_path = base_dir / 'subtitles.vtt' + self.assertTrue(vtt_path.exists()) + content = vtt_path.read_text() + self.assertIn('WEBVTT', content) + self.assertIn('Hello world', content) + + @override_settings( + STASHCAST_STT_MODEL='base', + STASHCAST_STT_LANGUAGE='en', + STASHCAST_STT_DEVICE='cpu', + STASHCAST_STT_COMPUTE_TYPE='int8', + STASHCAST_SUMMARY_SENTENCES=3, + ) + @patch('media.tasks.generate_summary') + @patch('faster_whisper.WhisperModel') + def test_transcribe_chains_to_summary(self, mock_whisper_class, mock_gen_summary): + """After transcription, summary generation is enqueued""" + from media.tasks import transcribe_media + + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Test')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-chain-summary', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='content.m4a', + log_path='download.log', + ) + + base_dir = item.get_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'content.m4a').write_bytes(b'fake audio') + + transcribe_media.call_local(item.guid) + + mock_gen_summary.assert_called_once_with(item.guid) + + @override_settings( + STASHCAST_STT_MODEL='base', + STASHCAST_STT_LANGUAGE='en', + STASHCAST_STT_DEVICE='cpu', + STASHCAST_STT_COMPUTE_TYPE='int8', + STASHCAST_SUMMARY_SENTENCES=0, + ) + @patch('media.tasks.generate_summary') + @patch('faster_whisper.WhisperModel') + def test_transcribe_skips_summary_when_zero_sentences( + self, mock_whisper_class, mock_gen_summary + ): + """Summary is not enqueued when STASHCAST_SUMMARY_SENTENCES is 0""" + from media.tasks import transcribe_media + + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Test')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-no-summary', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='content.m4a', + log_path='download.log', + ) + + base_dir = item.get_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'content.m4a').write_bytes(b'fake audio') + + transcribe_media.call_local(item.guid) + + mock_gen_summary.assert_not_called() + + @override_settings( + STASHCAST_STT_MODEL='base', + STASHCAST_STT_LANGUAGE='en', + STASHCAST_STT_DEVICE='cpu', + STASHCAST_STT_COMPUTE_TYPE='int8', + ) + @patch('faster_whisper.WhisperModel') + def test_transcribe_failure_does_not_crash_item(self, mock_whisper_class): + """Transcription failure is logged but item stays READY""" + from media.tasks import transcribe_media + + mock_whisper_class.side_effect = RuntimeError('model load failed') + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-transcribe-fail', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='content.m4a', + log_path='download.log', + ) + + base_dir = item.get_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'content.m4a').write_bytes(b'fake audio') + + # Should not raise + transcribe_media.call_local(item.guid) + + item.refresh_from_db() + # Item should still be READY, not ERROR + self.assertEqual(item.status, MediaItem.STATUS_READY) + # subtitle_path should still be empty + self.assertEqual(item.subtitle_path, '') + + @override_settings( + STASHCAST_STT_MODEL='base', + STASHCAST_STT_LANGUAGE='en', + STASHCAST_STT_DEVICE='cpu', + STASHCAST_STT_COMPUTE_TYPE='int8', + ) + @patch('faster_whisper.WhisperModel') + def test_transcribe_logs_timing(self, mock_whisper_class): + """Transcription timing is logged""" + from media.tasks import transcribe_media + + mock_model = MagicMock() + mock_whisper_class.return_value = mock_model + + segments = [SimpleNamespace(start=0.0, end=1.0, text='Test')] + info = SimpleNamespace(language='en', language_probability=0.99) + mock_model.transcribe.return_value = (iter(segments), info) + + item = MediaItem.objects.create( + source_url='https://example.com/audio.mp3', + requested_type=MediaItem.REQUESTED_TYPE_AUTO, + slug='test-timing-log', + media_type=MediaItem.MEDIA_TYPE_AUDIO, + status=MediaItem.STATUS_READY, + content_path='content.m4a', + log_path='download.log', + ) + + base_dir = item.get_base_dir() + base_dir.mkdir(parents=True, exist_ok=True) + (base_dir / 'content.m4a').write_bytes(b'fake audio') + + transcribe_media.call_local(item.guid) + + # Check that the log file contains timing info + log_file = base_dir / 'download.log' + self.assertTrue(log_file.exists()) + log_content = log_file.read_text() + self.assertIn('TRANSCRIBING', log_content) + self.assertIn('Transcription complete', log_content) + self.assertIn('took', log_content) diff --git a/requirements.txt b/requirements.txt index 5afea39..b521f80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -16,4 +16,5 @@ beautifulsoup4>=4.12.0 django-huey-monitor>=0.9.0 bx-django-utils>=80 django-environ>=0.12.0 -whitenoise>=6.11.0 \ No newline at end of file +whitenoise>=6.11.0 +faster-whisper>=1.1.0 \ No newline at end of file diff --git a/stashcast/settings.py b/stashcast/settings.py index 0a0e8af..2c75a2c 100644 --- a/stashcast/settings.py +++ b/stashcast/settings.py @@ -234,6 +234,29 @@ STASHCAST_SLUG_MAX_CHARS = int(os.environ.get('STASHCAST_SLUG_MAX_CHARS', '40')) STASHCAST_SUMMARY_SENTENCES = int(os.environ.get('STASHCAST_SUMMARY_SENTENCES', '8')) +# Speech-to-text transcription (opt-in, offline, uses faster-whisper) +# Set STASHCAST_STT_MODEL to enable (e.g. 'base', 'small', 'medium', 'large-v3') +# Leave empty/unset to disable transcription +# Model sizes and approximate VRAM/RAM usage: +# tiny ~75MB - fastest, least accurate +# base ~150MB - good balance for English +# small ~500MB - better multilingual accuracy +# medium ~1.5GB - high accuracy +# large-v3 ~3GB - best accuracy, needs GPU for reasonable speed +STASHCAST_STT_MODEL = os.environ.get('STASHCAST_STT_MODEL', '') + +# Language for speech-to-text: ISO code like 'en', 'es', 'pt', or 'auto' for detection +# Defaults to the same language as LANGUAGE_CODE +STASHCAST_STT_LANGUAGE = os.environ.get('STASHCAST_STT_LANGUAGE', STASHCAST_SUBTITLE_LANGUAGE) +if STASHCAST_STT_LANGUAGE == 'auto': + STASHCAST_STT_LANGUAGE = None # faster-whisper uses None for auto-detect + +# Device and compute type for faster-whisper +# Device: 'auto' (detect GPU), 'cpu', 'cuda' +# Compute: 'auto', 'int8' (CPU-friendly), 'float16' (GPU), 'float32' +STASHCAST_STT_DEVICE = os.environ.get('STASHCAST_STT_DEVICE', 'auto') +STASHCAST_STT_COMPUTE_TYPE = os.environ.get('STASHCAST_STT_COMPUTE_TYPE', 'auto') + # Optional: Proxy URL for yt-dlp requests # Use residential proxy to avoid YouTube blocking cloud VM IPs # Formats: http://host:port, socks5://host:port, socks5://user:pass@host:port