lyrics-transcriber 0.19.2__py3-none-any.whl → 0.30.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lyrics_transcriber/__init__.py +2 -5
- lyrics_transcriber/cli/main.py +194 -0
- lyrics_transcriber/core/__init__.py +0 -0
- lyrics_transcriber/core/controller.py +283 -0
- lyrics_transcriber/core/corrector.py +56 -0
- lyrics_transcriber/core/fetcher.py +143 -0
- lyrics_transcriber/output/__init__.py +0 -0
- lyrics_transcriber/output/generator.py +210 -0
- lyrics_transcriber/storage/__init__.py +0 -0
- lyrics_transcriber/storage/dropbox.py +249 -0
- lyrics_transcriber/storage/tokens.py +116 -0
- lyrics_transcriber/{audioshake_transcriber.py → transcribers/audioshake.py} +44 -15
- lyrics_transcriber/transcribers/base.py +31 -0
- lyrics_transcriber/transcribers/whisper.py +186 -0
- {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/METADATA +6 -17
- lyrics_transcriber-0.30.0.dist-info/RECORD +22 -0
- lyrics_transcriber-0.30.0.dist-info/entry_points.txt +3 -0
- lyrics_transcriber/llm_prompts/README.md +0 -10
- lyrics_transcriber/llm_prompts/llm_prompt_lyrics_correction_andrew_handwritten_20231118.txt +0 -55
- lyrics_transcriber/llm_prompts/llm_prompt_lyrics_correction_gpt_optimised_20231119.txt +0 -36
- lyrics_transcriber/llm_prompts/llm_prompt_lyrics_matching_andrew_handwritten_20231118.txt +0 -19
- lyrics_transcriber/llm_prompts/promptfooconfig.yaml +0 -61
- lyrics_transcriber/llm_prompts/test_data/ABBA-UnderAttack-Genius.txt +0 -48
- lyrics_transcriber/transcriber.py +0 -1128
- lyrics_transcriber/utils/cli.py +0 -179
- lyrics_transcriber-0.19.2.dist-info/RECORD +0 -18
- lyrics_transcriber-0.19.2.dist-info/entry_points.txt +0 -3
- /lyrics_transcriber/{utils → cli}/__init__.py +0 -0
- /lyrics_transcriber/{utils → output}/ass.py +0 -0
- /lyrics_transcriber/{utils → output}/subtitles.py +0 -0
- {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/LICENSE +0 -0
- {lyrics_transcriber-0.19.2.dist-info → lyrics_transcriber-0.30.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,210 @@
|
|
1
|
+
import os
|
2
|
+
import logging
|
3
|
+
from typing import Dict, Any, Optional
|
4
|
+
import subprocess
|
5
|
+
from datetime import timedelta
|
6
|
+
from .subtitles import create_styled_subtitles, LyricsScreen, LyricsLine, LyricSegment
|
7
|
+
|
8
|
+
|
9
|
+
class OutputGenerator:
|
10
|
+
"""Handles generation of various lyrics output formats."""
|
11
|
+
|
12
|
+
def __init__(
|
13
|
+
self,
|
14
|
+
logger: Optional[logging.Logger] = None,
|
15
|
+
output_dir: Optional[str] = None,
|
16
|
+
cache_dir: str = "/tmp/lyrics-transcriber-cache/",
|
17
|
+
video_resolution: str = "360p",
|
18
|
+
video_background_image: Optional[str] = None,
|
19
|
+
video_background_color: str = "black",
|
20
|
+
):
|
21
|
+
self.logger = logger or logging.getLogger(__name__)
|
22
|
+
self.output_dir = output_dir
|
23
|
+
self.cache_dir = cache_dir
|
24
|
+
|
25
|
+
# Video settings
|
26
|
+
self.video_resolution = video_resolution
|
27
|
+
self.video_background_image = video_background_image
|
28
|
+
self.video_background_color = video_background_color
|
29
|
+
|
30
|
+
# Set video resolution parameters
|
31
|
+
self.video_resolution_num, self.font_size, self.line_height = self._get_video_params(video_resolution)
|
32
|
+
|
33
|
+
# Validate video background if provided
|
34
|
+
if self.video_background_image and not os.path.isfile(self.video_background_image):
|
35
|
+
raise FileNotFoundError(f"Video background image not found: {self.video_background_image}")
|
36
|
+
|
37
|
+
def generate_outputs(
|
38
|
+
self, transcription_data: Dict[str, Any], output_prefix: str, audio_filepath: str, render_video: bool = False
|
39
|
+
) -> Dict[str, str]:
|
40
|
+
"""
|
41
|
+
Generate all requested output formats.
|
42
|
+
|
43
|
+
Args:
|
44
|
+
transcription_data: Dictionary containing transcription segments with timing
|
45
|
+
output_prefix: Prefix for output filenames
|
46
|
+
audio_filepath: Path to the source audio file
|
47
|
+
render_video: Whether to generate video output
|
48
|
+
|
49
|
+
Returns:
|
50
|
+
Dictionary of output paths for each format
|
51
|
+
"""
|
52
|
+
outputs = {}
|
53
|
+
|
54
|
+
try:
|
55
|
+
# Generate LRC
|
56
|
+
lrc_path = self.generate_lrc(transcription_data, output_prefix)
|
57
|
+
outputs["lrc"] = lrc_path
|
58
|
+
|
59
|
+
# Generate ASS
|
60
|
+
ass_path = self.generate_ass(transcription_data, output_prefix)
|
61
|
+
outputs["ass"] = ass_path
|
62
|
+
|
63
|
+
# Generate video if requested
|
64
|
+
if render_video:
|
65
|
+
video_path = self.generate_video(ass_path, audio_filepath, output_prefix)
|
66
|
+
outputs["video"] = video_path
|
67
|
+
|
68
|
+
except Exception as e:
|
69
|
+
self.logger.error(f"Error generating outputs: {str(e)}")
|
70
|
+
raise
|
71
|
+
|
72
|
+
return outputs
|
73
|
+
|
74
|
+
def generate_lrc(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
|
75
|
+
"""Generate LRC format lyrics file."""
|
76
|
+
self.logger.info("Generating LRC format lyrics")
|
77
|
+
|
78
|
+
output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.lrc")
|
79
|
+
|
80
|
+
try:
|
81
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
82
|
+
for segment in transcription_data["segments"]:
|
83
|
+
start_time = self._format_lrc_timestamp(segment["start"])
|
84
|
+
line = f"[{start_time}]{segment['text']}\n"
|
85
|
+
f.write(line)
|
86
|
+
|
87
|
+
self.logger.info(f"LRC file generated: {output_path}")
|
88
|
+
return output_path
|
89
|
+
|
90
|
+
except Exception as e:
|
91
|
+
self.logger.error(f"Failed to generate LRC file: {str(e)}")
|
92
|
+
raise
|
93
|
+
|
94
|
+
def generate_ass(self, transcription_data: Dict[str, Any], output_prefix: str) -> str:
|
95
|
+
"""Generate ASS format subtitles file."""
|
96
|
+
self.logger.info("Generating ASS format subtitles")
|
97
|
+
|
98
|
+
output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.ass")
|
99
|
+
|
100
|
+
try:
|
101
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
102
|
+
# Write ASS header
|
103
|
+
f.write(self._get_ass_header())
|
104
|
+
|
105
|
+
# Write events
|
106
|
+
for segment in transcription_data["segments"]:
|
107
|
+
start_time = self._format_ass_timestamp(segment["start"])
|
108
|
+
end_time = self._format_ass_timestamp(segment["end"])
|
109
|
+
line = f"Dialogue: 0,{start_time},{end_time},Default,,0,0,0,,{segment['text']}\n"
|
110
|
+
f.write(line)
|
111
|
+
|
112
|
+
self.logger.info(f"ASS file generated: {output_path}")
|
113
|
+
return output_path
|
114
|
+
|
115
|
+
except Exception as e:
|
116
|
+
self.logger.error(f"Failed to generate ASS file: {str(e)}")
|
117
|
+
raise
|
118
|
+
|
119
|
+
def generate_video(self, ass_path: str, audio_path: str, output_prefix: str) -> str:
|
120
|
+
"""Generate MP4 video with lyrics overlay."""
|
121
|
+
self.logger.info("Generating video with lyrics overlay")
|
122
|
+
|
123
|
+
output_path = os.path.join(self.output_dir or self.cache_dir, f"{output_prefix}.mp4")
|
124
|
+
width, height = self.video_resolution_num
|
125
|
+
|
126
|
+
try:
|
127
|
+
# Prepare FFmpeg command
|
128
|
+
cmd = [
|
129
|
+
"ffmpeg",
|
130
|
+
"-y",
|
131
|
+
"-f",
|
132
|
+
"lavfi",
|
133
|
+
"-i",
|
134
|
+
f"color=c={self.video_background_color}:s={width}x{height}",
|
135
|
+
"-i",
|
136
|
+
audio_path,
|
137
|
+
"-vf",
|
138
|
+
f"ass={ass_path}",
|
139
|
+
"-c:v",
|
140
|
+
"libx264",
|
141
|
+
"-c:a",
|
142
|
+
"aac",
|
143
|
+
"-shortest",
|
144
|
+
output_path,
|
145
|
+
]
|
146
|
+
|
147
|
+
# If background image provided, use it instead of solid color
|
148
|
+
if self.video_background_image:
|
149
|
+
cmd[3:6] = ["-i", self.video_background_image]
|
150
|
+
|
151
|
+
self.logger.debug(f"Running FFmpeg command: {' '.join(cmd)}")
|
152
|
+
subprocess.run(cmd, check=True)
|
153
|
+
|
154
|
+
self.logger.info(f"Video generated: {output_path}")
|
155
|
+
return output_path
|
156
|
+
|
157
|
+
except subprocess.CalledProcessError as e:
|
158
|
+
self.logger.error(f"FFmpeg error: {str(e)}")
|
159
|
+
raise
|
160
|
+
except Exception as e:
|
161
|
+
self.logger.error(f"Failed to generate video: {str(e)}")
|
162
|
+
raise
|
163
|
+
|
164
|
+
def _get_video_params(self, resolution: str) -> tuple:
|
165
|
+
"""Get video parameters based on resolution setting."""
|
166
|
+
match resolution:
|
167
|
+
case "4k":
|
168
|
+
return (3840, 2160), 250, 250
|
169
|
+
case "1080p":
|
170
|
+
return (1920, 1080), 120, 120
|
171
|
+
case "720p":
|
172
|
+
return (1280, 720), 100, 100
|
173
|
+
case "360p":
|
174
|
+
return (640, 360), 50, 50
|
175
|
+
case _:
|
176
|
+
raise ValueError("Invalid video_resolution value. Must be one of: 4k, 1080p, 720p, 360p")
|
177
|
+
|
178
|
+
def _format_lrc_timestamp(self, seconds: float) -> str:
|
179
|
+
"""Format timestamp for LRC format."""
|
180
|
+
time = timedelta(seconds=seconds)
|
181
|
+
minutes = int(time.total_seconds() / 60)
|
182
|
+
seconds = time.total_seconds() % 60
|
183
|
+
return f"{minutes:02d}:{seconds:05.2f}"
|
184
|
+
|
185
|
+
def _format_ass_timestamp(self, seconds: float) -> str:
|
186
|
+
"""Format timestamp for ASS format."""
|
187
|
+
time = timedelta(seconds=seconds)
|
188
|
+
hours = int(time.total_seconds() / 3600)
|
189
|
+
minutes = int((time.total_seconds() % 3600) / 60)
|
190
|
+
seconds = time.total_seconds() % 60
|
191
|
+
centiseconds = int((seconds % 1) * 100)
|
192
|
+
seconds = int(seconds)
|
193
|
+
return f"{hours}:{minutes:02d}:{seconds:02d}.{centiseconds:02d}"
|
194
|
+
|
195
|
+
def _get_ass_header(self) -> str:
|
196
|
+
"""Get ASS format header with style definitions."""
|
197
|
+
width, height = self.video_resolution_num
|
198
|
+
return f"""[Script Info]
|
199
|
+
ScriptType: v4.00+
|
200
|
+
PlayResX: {width}
|
201
|
+
PlayResY: {height}
|
202
|
+
WrapStyle: 0
|
203
|
+
|
204
|
+
[V4+ Styles]
|
205
|
+
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
|
206
|
+
Style: Default,Arial,{self.font_size},&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,2,2,10,10,10,1
|
207
|
+
|
208
|
+
[Events]
|
209
|
+
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
|
210
|
+
"""
|
File without changes
|
@@ -0,0 +1,249 @@
|
|
1
|
+
import dropbox
|
2
|
+
from dropbox import Dropbox
|
3
|
+
from dropbox.files import WriteMode
|
4
|
+
import os
|
5
|
+
import time
|
6
|
+
import logging
|
7
|
+
import requests
|
8
|
+
|
9
|
+
logger = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
|
12
|
+
class DropboxHandler:
|
13
|
+
def __init__(self, app_key=None, app_secret=None, refresh_token=None, access_token=None):
|
14
|
+
self.app_key = app_key or os.environ.get("WHISPER_DROPBOX_APP_KEY")
|
15
|
+
self.app_secret = app_secret or os.environ.get("WHISPER_DROPBOX_APP_SECRET")
|
16
|
+
self.refresh_token = refresh_token or os.environ.get("WHISPER_DROPBOX_REFRESH_TOKEN")
|
17
|
+
self.access_token = access_token or os.environ.get("WHISPER_DROPBOX_ACCESS_TOKEN")
|
18
|
+
self.dbx = Dropbox(self.access_token)
|
19
|
+
|
20
|
+
def _refresh_access_token(self):
|
21
|
+
"""Refresh the access token using the refresh token."""
|
22
|
+
try:
|
23
|
+
logger.debug("Attempting to refresh access token")
|
24
|
+
# Prepare the token refresh request
|
25
|
+
data = {"grant_type": "refresh_token", "refresh_token": self.refresh_token}
|
26
|
+
auth = (self.app_key, self.app_secret)
|
27
|
+
|
28
|
+
logger.debug(f"Making refresh token request to Dropbox API")
|
29
|
+
response = requests.post("https://api.dropbox.com/oauth2/token", data=data, auth=auth)
|
30
|
+
|
31
|
+
logger.debug(f"Received response from Dropbox API. Status code: {response.status_code}")
|
32
|
+
if response.status_code == 200:
|
33
|
+
result = response.json()
|
34
|
+
self.access_token = result["access_token"]
|
35
|
+
self.dbx = Dropbox(self.access_token)
|
36
|
+
logger.info("Successfully refreshed access token")
|
37
|
+
else:
|
38
|
+
logger.error(f"Failed to refresh token. Status code: {response.status_code}, Response: {response.text}")
|
39
|
+
|
40
|
+
except Exception as e:
|
41
|
+
logger.error(f"Error refreshing access token: {str(e)}", exc_info=True)
|
42
|
+
raise
|
43
|
+
|
44
|
+
@staticmethod
|
45
|
+
def _handle_auth_error(func):
|
46
|
+
"""Decorator to handle authentication errors and retry with refreshed token."""
|
47
|
+
|
48
|
+
def wrapper(self, *args, **kwargs):
|
49
|
+
try:
|
50
|
+
logger.debug(f"Executing {func.__name__} with args: {args}, kwargs: {kwargs}")
|
51
|
+
return func(self, *args, **kwargs)
|
52
|
+
except (dropbox.exceptions.AuthError, dropbox.exceptions.ApiError) as e:
|
53
|
+
logger.debug(f"Caught error in {func.__name__}: {str(e)}")
|
54
|
+
if "expired_access_token" in str(e):
|
55
|
+
logger.info(f"Access token expired in {func.__name__}, attempting refresh")
|
56
|
+
self._refresh_access_token()
|
57
|
+
logger.debug(f"Retrying {func.__name__} after token refresh")
|
58
|
+
return func(self, *args, **kwargs)
|
59
|
+
logger.error(f"Unhandled Dropbox error in {func.__name__}: {str(e)}")
|
60
|
+
raise
|
61
|
+
|
62
|
+
return wrapper
|
63
|
+
|
64
|
+
@_handle_auth_error
|
65
|
+
def upload_with_retry(self, file, path, max_retries=3):
|
66
|
+
"""Upload a file to Dropbox with retries."""
|
67
|
+
for attempt in range(max_retries):
|
68
|
+
try:
|
69
|
+
logger.debug(f"Attempting file upload to {path} (attempt {attempt + 1}/{max_retries})")
|
70
|
+
file.seek(0)
|
71
|
+
self.dbx.files_upload(file.read(), path, mode=WriteMode.overwrite)
|
72
|
+
logger.debug(f"Successfully uploaded file to {path}")
|
73
|
+
return
|
74
|
+
except dropbox.exceptions.ApiError as e:
|
75
|
+
logger.warning(f"Upload attempt {attempt + 1} failed: {str(e)}")
|
76
|
+
if attempt == max_retries - 1:
|
77
|
+
logger.error(f"All upload attempts failed for {path}: {str(e)}")
|
78
|
+
raise
|
79
|
+
sleep_time = 1 * (attempt + 1)
|
80
|
+
logger.debug(f"Waiting {sleep_time} seconds before retry")
|
81
|
+
time.sleep(sleep_time)
|
82
|
+
|
83
|
+
@_handle_auth_error
|
84
|
+
def upload_string_with_retry(self, content, path, max_retries=3):
|
85
|
+
"""Upload a string content to Dropbox with retries."""
|
86
|
+
for attempt in range(max_retries):
|
87
|
+
try:
|
88
|
+
logger.debug(f"Attempting string upload to {path} (attempt {attempt + 1}/{max_retries})")
|
89
|
+
self.dbx.files_upload(content.encode(), path, mode=WriteMode.overwrite)
|
90
|
+
logger.debug(f"Successfully uploaded string content to {path}")
|
91
|
+
return
|
92
|
+
except dropbox.exceptions.ApiError as e:
|
93
|
+
logger.warning(f"Upload attempt {attempt + 1} failed: {str(e)}")
|
94
|
+
if attempt == max_retries - 1:
|
95
|
+
logger.error(f"All upload attempts failed for {path}: {str(e)}")
|
96
|
+
raise
|
97
|
+
sleep_time = 1 * (attempt + 1)
|
98
|
+
logger.debug(f"Waiting {sleep_time} seconds before retry")
|
99
|
+
time.sleep(sleep_time)
|
100
|
+
|
101
|
+
@_handle_auth_error
|
102
|
+
def list_folder_recursive(self, path=""):
|
103
|
+
"""List all files in a folder recursively."""
|
104
|
+
try:
|
105
|
+
logger.debug(f"Listing files recursively from {path}")
|
106
|
+
entries = []
|
107
|
+
result = self.dbx.files_list_folder(path, recursive=True)
|
108
|
+
|
109
|
+
while True:
|
110
|
+
entries.extend(result.entries)
|
111
|
+
if not result.has_more:
|
112
|
+
break
|
113
|
+
logger.debug("Fetching more results from Dropbox")
|
114
|
+
result = self.dbx.files_list_folder_continue(result.cursor)
|
115
|
+
|
116
|
+
return entries
|
117
|
+
|
118
|
+
except (dropbox.exceptions.AuthError, dropbox.exceptions.ApiError):
|
119
|
+
# Let the decorator handle these
|
120
|
+
raise
|
121
|
+
except Exception as e:
|
122
|
+
logger.error(f"Error listing files from Dropbox: {str(e)}", exc_info=True)
|
123
|
+
raise
|
124
|
+
|
125
|
+
@_handle_auth_error
|
126
|
+
def download_file_content(self, path):
|
127
|
+
"""Download and return the content of a file."""
|
128
|
+
try:
|
129
|
+
logger.debug(f"Downloading file content from {path}")
|
130
|
+
return self.dbx.files_download(path)[1].content
|
131
|
+
except Exception as e:
|
132
|
+
logger.error(f"Error downloading file from {path}: {str(e)}", exc_info=True)
|
133
|
+
raise
|
134
|
+
|
135
|
+
@_handle_auth_error
|
136
|
+
def download_folder(self, dropbox_path, local_path):
|
137
|
+
"""Download all files from a Dropbox folder to a local path."""
|
138
|
+
try:
|
139
|
+
logger.debug(f"Downloading folder {dropbox_path} to {local_path}")
|
140
|
+
|
141
|
+
# List all files in the folder
|
142
|
+
result = self.dbx.files_list_folder(dropbox_path, recursive=True)
|
143
|
+
entries = result.entries
|
144
|
+
|
145
|
+
# Continue fetching if there are more files
|
146
|
+
while result.has_more:
|
147
|
+
result = self.dbx.files_list_folder_continue(result.cursor)
|
148
|
+
entries.extend(result.entries)
|
149
|
+
|
150
|
+
# Download each file
|
151
|
+
for entry in entries:
|
152
|
+
if isinstance(entry, dropbox.files.FileMetadata):
|
153
|
+
# Calculate relative path from the root folder
|
154
|
+
rel_path = entry.path_display[len(dropbox_path) :].lstrip("/")
|
155
|
+
local_file_path = os.path.join(local_path, rel_path)
|
156
|
+
|
157
|
+
# Create directories if they don't exist
|
158
|
+
os.makedirs(os.path.dirname(local_file_path), exist_ok=True)
|
159
|
+
|
160
|
+
# Download the file
|
161
|
+
logger.debug(f"Downloading {entry.path_display} to {local_file_path}")
|
162
|
+
self.dbx.files_download_to_file(local_file_path, entry.path_display)
|
163
|
+
|
164
|
+
logger.debug(f"Successfully downloaded folder {dropbox_path} to {local_path}")
|
165
|
+
|
166
|
+
except Exception as e:
|
167
|
+
logger.error(f"Error downloading folder {dropbox_path}: {str(e)}", exc_info=True)
|
168
|
+
raise
|
169
|
+
|
170
|
+
@_handle_auth_error
|
171
|
+
def upload_folder(self, local_path, dropbox_path):
|
172
|
+
"""Upload all files from a local folder to a Dropbox path."""
|
173
|
+
try:
|
174
|
+
logger.debug(f"Uploading folder {local_path} to {dropbox_path}")
|
175
|
+
|
176
|
+
# Walk through all files in the local folder
|
177
|
+
for root, dirs, files in os.walk(local_path):
|
178
|
+
for filename in files:
|
179
|
+
local_file_path = os.path.join(root, filename)
|
180
|
+
# Calculate relative path from local_path
|
181
|
+
rel_path = os.path.relpath(local_file_path, local_path)
|
182
|
+
target_path = f"{dropbox_path}/{rel_path}"
|
183
|
+
|
184
|
+
logger.debug(f"Uploading {rel_path} to {target_path}")
|
185
|
+
with open(local_file_path, "rb") as f:
|
186
|
+
self.dbx.files_upload(f.read(), target_path, mode=WriteMode.overwrite)
|
187
|
+
|
188
|
+
logger.debug(f"Successfully uploaded folder {local_path} to {dropbox_path}")
|
189
|
+
|
190
|
+
except Exception as e:
|
191
|
+
logger.error(f"Error uploading folder {local_path}: {str(e)}", exc_info=True)
|
192
|
+
raise
|
193
|
+
|
194
|
+
@_handle_auth_error
|
195
|
+
def create_shared_link(self, path):
|
196
|
+
"""Create a shared link for a file that's accessible without login."""
|
197
|
+
try:
|
198
|
+
logger.debug(f"Creating shared link for {path}")
|
199
|
+
shared_link = self.dbx.sharing_create_shared_link_with_settings(
|
200
|
+
path, settings=dropbox.sharing.SharedLinkSettings(requested_visibility=dropbox.sharing.RequestedVisibility.public)
|
201
|
+
)
|
202
|
+
# Convert dropbox shared link to direct download link
|
203
|
+
return shared_link.url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
|
204
|
+
except Exception as e:
|
205
|
+
logger.error(f"Error creating shared link: {str(e)}", exc_info=True)
|
206
|
+
raise
|
207
|
+
|
208
|
+
@_handle_auth_error
|
209
|
+
def get_existing_shared_link(self, path):
|
210
|
+
"""Get existing shared link for a file if it exists."""
|
211
|
+
try:
|
212
|
+
logger.debug(f"Getting existing shared link for {path}")
|
213
|
+
shared_links = self.dbx.sharing_list_shared_links(path=path).links
|
214
|
+
if shared_links:
|
215
|
+
# Convert to direct download link
|
216
|
+
return shared_links[0].url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
|
217
|
+
return None
|
218
|
+
except Exception as e:
|
219
|
+
logger.error(f"Error getting existing shared link: {str(e)}", exc_info=True)
|
220
|
+
return None
|
221
|
+
|
222
|
+
@_handle_auth_error
|
223
|
+
def create_or_get_shared_link(self, path):
|
224
|
+
"""Create a shared link or get existing one."""
|
225
|
+
try:
|
226
|
+
# First try to get existing link
|
227
|
+
existing_link = self.get_existing_shared_link(path)
|
228
|
+
if existing_link:
|
229
|
+
logger.debug(f"Found existing shared link for {path}")
|
230
|
+
return existing_link
|
231
|
+
|
232
|
+
# If no existing link, create new one
|
233
|
+
logger.debug(f"Creating new shared link for {path}")
|
234
|
+
shared_link = self.dbx.sharing_create_shared_link_with_settings(
|
235
|
+
path, settings=dropbox.sharing.SharedLinkSettings(requested_visibility=dropbox.sharing.RequestedVisibility.public)
|
236
|
+
)
|
237
|
+
return shared_link.url.replace("www.dropbox.com", "dl.dropboxusercontent.com")
|
238
|
+
except Exception as e:
|
239
|
+
logger.error(f"Error creating or getting shared link: {str(e)}", exc_info=True)
|
240
|
+
raise
|
241
|
+
|
242
|
+
@_handle_auth_error
|
243
|
+
def file_exists(self, path):
|
244
|
+
"""Check if a file exists in Dropbox."""
|
245
|
+
try:
|
246
|
+
self.dbx.files_get_metadata(path)
|
247
|
+
return True
|
248
|
+
except:
|
249
|
+
return False
|
@@ -0,0 +1,116 @@
|
|
1
|
+
#! /usr/bin/env python3
|
2
|
+
import http.server
|
3
|
+
import socketserver
|
4
|
+
import webbrowser
|
5
|
+
import urllib.parse
|
6
|
+
import requests
|
7
|
+
from dotenv import load_dotenv
|
8
|
+
import base64
|
9
|
+
from threading import Thread
|
10
|
+
import argparse
|
11
|
+
|
12
|
+
# Load environment variables
|
13
|
+
load_dotenv()
|
14
|
+
|
15
|
+
REDIRECT_URL = "http://localhost:53682/"
|
16
|
+
|
17
|
+
# Store the authorization code when received
|
18
|
+
auth_code = None
|
19
|
+
|
20
|
+
|
21
|
+
class OAuthHandler(http.server.SimpleHTTPRequestHandler):
|
22
|
+
def do_GET(self):
|
23
|
+
global auth_code
|
24
|
+
# Parse the query parameters
|
25
|
+
query = urllib.parse.urlparse(self.path).query
|
26
|
+
params = urllib.parse.parse_qs(query)
|
27
|
+
|
28
|
+
if "code" in params:
|
29
|
+
auth_code = params["code"][0]
|
30
|
+
# Send success response
|
31
|
+
self.send_response(200)
|
32
|
+
self.send_header("Content-type", "text/html")
|
33
|
+
self.end_headers()
|
34
|
+
self.wfile.write(b"Authorization successful! You can close this window.")
|
35
|
+
|
36
|
+
# Shutdown the server
|
37
|
+
Thread(target=self.server.shutdown).start()
|
38
|
+
else:
|
39
|
+
# Handle error or other cases
|
40
|
+
self.send_response(400)
|
41
|
+
self.send_header("Content-type", "text/html")
|
42
|
+
self.end_headers()
|
43
|
+
self.wfile.write(b"Authorization failed!")
|
44
|
+
|
45
|
+
|
46
|
+
def get_tokens(app_key, app_secret):
|
47
|
+
# Construct the authorization URL
|
48
|
+
auth_url = (
|
49
|
+
"https://www.dropbox.com/oauth2/authorize"
|
50
|
+
f"?client_id={app_key}"
|
51
|
+
f"&redirect_uri={REDIRECT_URL}"
|
52
|
+
"&response_type=code"
|
53
|
+
"&token_access_type=offline"
|
54
|
+
)
|
55
|
+
|
56
|
+
# Start local server
|
57
|
+
port = int(REDIRECT_URL.split(":")[-1].strip("/"))
|
58
|
+
httpd = socketserver.TCPServer(("", port), OAuthHandler)
|
59
|
+
|
60
|
+
print(f"Opening browser for Dropbox authorization...")
|
61
|
+
webbrowser.open(auth_url)
|
62
|
+
|
63
|
+
print(f"Waiting for authorization...")
|
64
|
+
httpd.serve_forever()
|
65
|
+
|
66
|
+
if auth_code:
|
67
|
+
print("Authorization code received, exchanging for tokens...")
|
68
|
+
|
69
|
+
# Exchange authorization code for tokens
|
70
|
+
auth = base64.b64encode(f"{app_key}:{app_secret}".encode()).decode()
|
71
|
+
response = requests.post(
|
72
|
+
"https://api.dropbox.com/oauth2/token",
|
73
|
+
data={"code": auth_code, "grant_type": "authorization_code", "redirect_uri": REDIRECT_URL},
|
74
|
+
headers={"Authorization": f"Basic {auth}"},
|
75
|
+
)
|
76
|
+
|
77
|
+
if response.status_code == 200:
|
78
|
+
tokens = response.json()
|
79
|
+
print("\nTokens received successfully!")
|
80
|
+
print("\nAdd these lines to your .env file:")
|
81
|
+
print(f"WHISPER_DROPBOX_APP_KEY={app_key}")
|
82
|
+
print(f"WHISPER_DROPBOX_APP_SECRET={app_secret}")
|
83
|
+
print(f"WHISPER_DROPBOX_ACCESS_TOKEN={tokens['access_token']}")
|
84
|
+
print(f"WHISPER_DROPBOX_REFRESH_TOKEN={tokens['refresh_token']}")
|
85
|
+
|
86
|
+
# Optionally update .env file directly
|
87
|
+
update = input("\nWould you like to update your .env file automatically? (y/n): ")
|
88
|
+
if update.lower() == "y":
|
89
|
+
with open(".env", "r") as f:
|
90
|
+
lines = f.readlines()
|
91
|
+
|
92
|
+
with open(".env", "w") as f:
|
93
|
+
for line in lines:
|
94
|
+
if line.startswith("WHISPER_DROPBOX_APP_KEY="):
|
95
|
+
f.write(f"WHISPER_DROPBOX_APP_KEY={app_key}\n")
|
96
|
+
elif line.startswith("WHISPER_DROPBOX_APP_SECRET="):
|
97
|
+
f.write(f"WHISPER_DROPBOX_APP_SECRET={app_secret}\n")
|
98
|
+
elif line.startswith("WHISPER_DROPBOX_ACCESS_TOKEN="):
|
99
|
+
f.write(f"WHISPER_DROPBOX_ACCESS_TOKEN={tokens['access_token']}\n")
|
100
|
+
elif line.startswith("WHISPER_DROPBOX_REFRESH_TOKEN="):
|
101
|
+
f.write(f"WHISPER_DROPBOX_REFRESH_TOKEN={tokens['refresh_token']}\n")
|
102
|
+
else:
|
103
|
+
f.write(line)
|
104
|
+
print("Updated .env file successfully!")
|
105
|
+
else:
|
106
|
+
print("Error exchanging authorization code for tokens:")
|
107
|
+
print(response.text)
|
108
|
+
|
109
|
+
|
110
|
+
if __name__ == "__main__":
|
111
|
+
parser = argparse.ArgumentParser(description="Get Dropbox OAuth tokens.")
|
112
|
+
parser.add_argument("--app-key", required=True, help="Dropbox App Key")
|
113
|
+
parser.add_argument("--app-secret", required=True, help="Dropbox App Secret")
|
114
|
+
|
115
|
+
args = parser.parse_args()
|
116
|
+
get_tokens(args.app_key, args.app_secret)
|
@@ -2,19 +2,53 @@ import requests
|
|
2
2
|
import time
|
3
3
|
import os
|
4
4
|
import json
|
5
|
+
from .base import BaseTranscriber
|
5
6
|
|
6
7
|
|
7
|
-
class AudioShakeTranscriber:
|
8
|
-
|
9
|
-
|
8
|
+
class AudioShakeTranscriber(BaseTranscriber):
|
9
|
+
"""Transcription service using AudioShake's API."""
|
10
|
+
|
11
|
+
def __init__(self, api_token=None, logger=None, output_prefix=None):
|
12
|
+
super().__init__(logger)
|
13
|
+
self.api_token = api_token or os.getenv("AUDIOSHAKE_API_TOKEN")
|
10
14
|
self.base_url = "https://groovy.audioshake.ai"
|
11
|
-
self.logger = logger
|
12
15
|
self.output_prefix = output_prefix
|
13
16
|
|
14
|
-
|
15
|
-
|
17
|
+
if not self.api_token:
|
18
|
+
raise ValueError("AudioShake API token must be provided either directly or via AUDIOSHAKE_API_TOKEN env var")
|
19
|
+
|
20
|
+
def get_name(self) -> str:
|
21
|
+
return "AudioShake"
|
22
|
+
|
23
|
+
def transcribe(self, audio_filepath: str) -> dict:
|
24
|
+
"""
|
25
|
+
Transcribe an audio file using AudioShake API.
|
26
|
+
|
27
|
+
Args:
|
28
|
+
audio_filepath: Path to the audio file to transcribe
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
Dict containing:
|
32
|
+
- segments: List of segments with start/end times and word-level data
|
33
|
+
- text: Full text transcription
|
34
|
+
- metadata: Dict of additional info
|
35
|
+
"""
|
16
36
|
self.logger.info(f"Starting transcription for {audio_filepath} using AudioShake API")
|
17
37
|
|
38
|
+
# Start job and get results
|
39
|
+
job_id = self.start_transcription(audio_filepath)
|
40
|
+
result = self.get_transcription_result(job_id)
|
41
|
+
|
42
|
+
# Add metadata to the result
|
43
|
+
result["metadata"] = {
|
44
|
+
"service": self.get_name(),
|
45
|
+
"language": "en", # AudioShake currently only supports English
|
46
|
+
}
|
47
|
+
|
48
|
+
return result
|
49
|
+
|
50
|
+
def start_transcription(self, audio_filepath: str) -> str:
|
51
|
+
"""Starts the transcription job and returns the job ID."""
|
18
52
|
# Step 1: Upload the audio file
|
19
53
|
asset_id = self._upload_file(audio_filepath)
|
20
54
|
self.logger.info(f"File uploaded successfully. Asset ID: {asset_id}")
|
@@ -25,22 +59,17 @@ class AudioShakeTranscriber:
|
|
25
59
|
|
26
60
|
return job_id
|
27
61
|
|
28
|
-
def get_transcription_result(self, job_id):
|
29
|
-
"""Gets the results for a previously started job"""
|
62
|
+
def get_transcription_result(self, job_id: str) -> dict:
|
63
|
+
"""Gets the results for a previously started job."""
|
30
64
|
self.logger.info(f"Getting results for job ID: {job_id}")
|
31
65
|
|
32
|
-
#
|
66
|
+
# Wait for job completion and get results
|
33
67
|
result = self._get_job_result(job_id)
|
34
68
|
self.logger.info(f"Job completed. Processing results...")
|
35
69
|
|
36
|
-
#
|
70
|
+
# Process and return in standard format
|
37
71
|
return self._process_result(result)
|
38
72
|
|
39
|
-
def transcribe(self, audio_filepath):
|
40
|
-
"""Original method now just combines the two steps"""
|
41
|
-
job_id = self.start_transcription(audio_filepath)
|
42
|
-
return self.get_transcription_result(job_id)
|
43
|
-
|
44
73
|
def _upload_file(self, filepath):
|
45
74
|
self.logger.info(f"Uploading {filepath} to AudioShake")
|
46
75
|
url = f"{self.base_url}/upload"
|