Repository navigation
Expand file tree
/
Copy pathdatabase.go
More file actions
344 lines (293 loc) · 9.6 KB
/
Copy pathdatabase.go
File metadata and controls
344 lines (293 loc) · 9.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
package main
import (
"context"
"database/sql"
"fmt"
"net/url"
"github.com/doug-martin/goqu/v9"
_ "github.com/doug-martin/goqu/v9/dialect/sqlite3"
_ "modernc.org/sqlite"
"subbed/internal/logger"
)
// Repository handles all database operations
type Repository struct {
db *goqu.Database
}
// VideoWithSubs represents a video with its subtitles
type VideoWithSubs struct {
Video
Subtitles []Subtitle `json:"subtitles"`
}
// sqlitePragmas are set on every connection when it opens, in this order. Many of them (foreign_keys, busy_timeout,
// synchronous, ...) apply to one connection only, so a PRAGMA statement run once would miss the rest of the pool;
// without foreign_keys, deleting a job or video could leave its logs, jobs and subtitles behind.
var sqlitePragmas = []struct{ name, value string }{
// these two only take effect when the database is created, so they come before journal_mode writes anything
{"page_size", "4096"},
{"auto_vacuum", "INCREMENTAL"},
{"journal_mode", "WAL"}, // Write-Ahead Logging for better concurrency
{"synchronous", "NORMAL"}, // Balanced durability/performance
{"cache_size", "-64000"}, // 64MB cache
{"busy_timeout", "5000"}, // 5 second timeout for locked database
{"foreign_keys", "1"}, // Enforce foreign key constraints
{"temp_store", "MEMORY"}, // Store temp tables in memory
{"mmap_size", "268435456"}, // 256MB memory-mapped I/O
{"journal_size_limit", "67108864"}, // 64MB journal size limit
{"wal_autocheckpoint", "1000"}, // Checkpoint every 1000 pages
}
// sqliteDSN returns the connection string for the database file at path, with the pragmas.
func sqliteDSN(path string) string {
query := url.Values{}
for _, pragma := range sqlitePragmas {
query.Add("_pragma", pragma.name+"("+pragma.value+")")
}
// OmitHost keeps a relative path as "file:./x.db"; the URL escapes characters such as ? and # in the path
return (&url.URL{Scheme: "file", OmitHost: true, Path: path, RawQuery: query.Encode()}).String()
}
// NewRepository creates a new repository instance
func NewRepository(dbPath string) (*Repository, error) {
sqlDB, err := sql.Open("sqlite", sqliteDSN(dbPath))
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
db := goqu.New("sqlite3", sqlDB)
repo := &Repository{db: db}
if err := repo.initDB(); err != nil {
return nil, fmt.Errorf("failed to initialize database: %w", err)
}
return repo, nil
}
// Close closes the database connection
func (r *Repository) Close() error {
if sqlDB, ok := r.db.Db.(*sql.DB); ok {
return sqlDB.Close()
}
return nil
}
// schema creates every table. There are no migrations: while subbed is a prototype, a changed table
// means starting from a fresh database.
const schema = `
CREATE TABLE IF NOT EXISTS videos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
original_url TEXT NOT NULL UNIQUE,
title TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS subtitles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL,
language TEXT NOT NULL,
type TEXT NOT NULL,
content TEXT NOT NULL,
UNIQUE (video_id, language),
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
);
-- browser cookies for yt-dlp
CREATE TABLE IF NOT EXISTS cookies (
domain TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT NOT NULL,
path TEXT NOT NULL DEFAULT '/',
secure INTEGER NOT NULL DEFAULT 0,
http_only INTEGER NOT NULL DEFAULT 0,
expires INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (domain, key)
);
-- transcribe and translate jobs, and what they logged
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
video_id INTEGER NOT NULL,
kind TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
stage TEXT NOT NULL DEFAULT '',
languages TEXT NOT NULL,
source_language TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (video_id) REFERENCES videos(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS job_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
at INTEGER NOT NULL,
level TEXT NOT NULL,
message TEXT NOT NULL,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS job_logs_job ON job_logs (job_id, id);
-- settings that the admin can change
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`
// initDB creates the database tables if they don't exist
func (r *Repository) initDB() error {
sqlDB, ok := r.db.Db.(*sql.DB)
if !ok {
return fmt.Errorf("failed to get sql.DB instance")
}
if _, err := sqlDB.Exec(schema); err != nil {
return fmt.Errorf("failed to create tables: %w", err)
}
return nil
}
// GetVideoByURL finds a video by a URL pattern containing the video ID
func (r *Repository) GetVideoByURL(ctx context.Context, videoID string) (*Video, error) {
var video Video
found, err := r.db.From("videos").
Select("id", "original_url", "title").
Where(goqu.L("original_url LIKE ?", "%"+videoID+"%")).
ScanStructContext(ctx, &video)
if err != nil {
return nil, fmt.Errorf("failed to query video: %w", err)
}
if !found {
return nil, sql.ErrNoRows
}
return &video, nil
}
// GetSubtitle returns a video's subtitle in one language, or sql.ErrNoRows.
func (r *Repository) GetSubtitle(ctx context.Context, videoID int, language string) (*Subtitle, error) {
var subtitle Subtitle
found, err := r.db.From("subtitles").
Select("id", "video_id", "language", "type", "content").
Where(goqu.C("video_id").Eq(videoID), goqu.C("language").Eq(language)).
ScanStructContext(ctx, &subtitle)
if err != nil {
return nil, fmt.Errorf("failed to query subtitle: %w", err)
}
if !found {
return nil, sql.ErrNoRows
}
return &subtitle, nil
}
// GetSubtitlesByVideoID retrieves all subtitles for a given video ID
func (r *Repository) GetSubtitlesByVideoID(ctx context.Context, videoID int) ([]Subtitle, error) {
var subtitles []Subtitle
err := r.db.From("subtitles").
Select("id", "video_id", "language", "type", "content").
Where(goqu.C("video_id").Eq(videoID)).
Order(goqu.C("id").Asc()).
ScanStructsContext(ctx, &subtitles)
if err != nil {
return nil, fmt.Errorf("failed to query subtitles: %w", err)
}
if subtitles == nil {
subtitles = []Subtitle{}
}
return subtitles, nil
}
// ListAllVideos retrieves all videos with their subtitles
func (r *Repository) ListAllVideos(ctx context.Context) ([]VideoWithSubs, error) {
// First get all videos
var videos []Video
err := r.db.From("videos").
Select("id", "original_url", "title").
Order(goqu.C("id").Desc()).
ScanStructsContext(ctx, &videos)
if err != nil {
return nil, fmt.Errorf("failed to query videos: %w", err)
}
if videos == nil {
return []VideoWithSubs{}, nil
}
// For each video, get its subtitles
result := make([]VideoWithSubs, 0, len(videos))
for _, video := range videos {
var subtitles []Subtitle
err := r.db.From("subtitles").
Select("id", "video_id", "language", "type").
Where(goqu.C("video_id").Eq(video.ID)).
Order(goqu.C("id").Asc()).
ScanStructsContext(ctx, &subtitles)
if err != nil {
logger.FromCtx(ctx).Warn("Failed to get subtitles for video",
"video_id", video.ID,
"error", err)
subtitles = []Subtitle{}
}
if subtitles == nil {
subtitles = []Subtitle{}
}
result = append(result, VideoWithSubs{
Video: video,
Subtitles: subtitles,
})
}
return result, nil
}
// CreateVideo inserts a new video and returns its ID
func (r *Repository) CreateVideo(ctx context.Context, url, title string) (int64, error) {
result, err := r.db.Insert("videos").
Rows(goqu.Record{"original_url": url, "title": title}).
Executor().
ExecContext(ctx)
if err != nil {
return 0, fmt.Errorf("failed to insert video: %w", err)
}
id, err := result.LastInsertId()
if err != nil {
return 0, fmt.Errorf("failed to get last insert id: %w", err)
}
return id, nil
}
// DeleteVideo removes a video by ID
func (r *Repository) DeleteVideo(ctx context.Context, id int) error {
_, err := r.db.Delete("videos").
Where(goqu.C("id").Eq(id)).
Executor().
ExecContext(ctx)
if err != nil {
return fmt.Errorf("failed to delete video: %w", err)
}
return nil
}
// UpdateVideoTitle changes a video's title and reports whether the video exists
func (r *Repository) UpdateVideoTitle(ctx context.Context, id int, title string) (bool, error) {
result, err := r.db.Update("videos").
Set(goqu.Record{"title": title}).
Where(goqu.C("id").Eq(id)).
Executor().
ExecContext(ctx)
if err != nil {
return false, fmt.Errorf("failed to update video: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("failed to read update result: %w", err)
}
return affected > 0, nil
}
// SaveSubtitle stores a subtitle, replacing the video's existing one in the same language
func (r *Repository) SaveSubtitle(ctx context.Context, videoID int, language, subType, content string) error {
_, err := r.db.Insert("subtitles").
Rows(goqu.Record{
"video_id": videoID,
"language": language,
"type": subType,
"content": content,
}).
OnConflict(goqu.DoUpdate("video_id, language", goqu.Record{
"type": goqu.L("excluded.type"),
"content": goqu.L("excluded.content"),
})).
Executor().
ExecContext(ctx)
if err != nil {
return fmt.Errorf("failed to save subtitle: %w", err)
}
return nil
}
// DeleteSubtitle removes a subtitle by ID
func (r *Repository) DeleteSubtitle(ctx context.Context, id int) error {
_, err := r.db.Delete("subtitles").
Where(goqu.C("id").Eq(id)).
Executor().
ExecContext(ctx)
if err != nil {
return fmt.Errorf("failed to delete subtitle: %w", err)
}
return nil
}