hako/internal/archival/domain/archive_service.go
Vibe Kanban 0afef05099
feat: add yt-dlp support for video downloads
Add a new archiver to download videos using yt-dlp. The new extractor should call the yt-dlp binary to download the video and we should track progress in the output and return code. The default rules should be updated so youtube videos are extracted using this extractor. The extractor default config should get the thumbnail as well (so we don't depend on the thumbnail extractor) and subtitles. Update the dockerfile accordingly so we not only have yt-dlp but it's required dependencies as well. Prefer installing from packages, if possible.
2026-01-29 12:52:29 +01:00

411 lines
14 KiB
Go

package domain
import (
"context"
"encoding/json"
"fmt"
"time"
"git.nakama.town/fmartingr/hako/internal/archival/archiver"
archivalRules "git.nakama.town/fmartingr/hako/internal/archival/rules"
archivalStore "git.nakama.town/fmartingr/hako/internal/archival/store"
"git.nakama.town/fmartingr/hako/internal/dependencies"
"git.nakama.town/fmartingr/hako/internal/model"
"github.com/google/uuid"
)
// ArchiveDomain handles business logic for archives
type ArchiveDomain struct {
deps model.Dependencies
}
// NewArchiveDomain creates a new ArchiveDomain
func NewArchiveDomain(deps model.Dependencies) model.ArchiveDomain {
return &ArchiveDomain{deps: deps}
}
// getDeps returns the concrete dependencies implementation
func (d *ArchiveDomain) getDeps() *dependencies.Dependencies {
return d.deps.(*dependencies.Dependencies)
}
// ConfigToMapConverter defines an interface for config types that can convert themselves to maps
type ConfigToMapConverter interface {
ToMap() map[string]any
}
// configToMap converts an archiver default config (struct or map) to map[string]any for ApplyConfig
func configToMap(cfg any) map[string]any {
// If it's already a map, return it directly
if m, ok := cfg.(map[string]any); ok {
return m
}
// If it implements ConfigToMapConverter, use its ToMap method
if converter, ok := cfg.(ConfigToMapConverter); ok {
return converter.ToMap()
}
// Fallback: use JSON marshaling/unmarshaling
data, err := json.Marshal(cfg)
if err != nil {
return nil
}
var out map[string]any
_ = json.Unmarshal(data, &out)
return out
}
// GetArchive retrieves a single archive by ID
func (d *ArchiveDomain) GetArchive(ctx context.Context, archiveID string) (*model.Archive, error) {
deps := d.getDeps()
archive, err := deps.ArchiveStore.GetByID(ctx, archiveID)
if err != nil {
return nil, fmt.Errorf("failed to get archive: %w", err)
}
return archive, nil
}
// GetArchiveHistory retrieves all archives for a link
func (d *ArchiveDomain) GetArchiveHistory(ctx context.Context, linkID string) ([]*model.Archive, error) {
deps := d.getDeps()
opts := archivalStore.ArchiveListOptions{LinkID: linkID}
archives, err := deps.ArchiveStore.ListByLinkID(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to get archive history: %w", err)
}
return archives, nil
}
// ReArchiveLink creates a new archive for a link and enqueues the job
func (d *ArchiveDomain) ReArchiveLink(ctx context.Context, linkID string) error {
deps := d.getDeps()
// Get the link first to access userID and URL
storeLink, err := deps.LinkStore.GetByID(ctx, linkID)
if err != nil {
return fmt.Errorf("failed to get link: %w", err)
}
// Analyze URL using HEAD request to get current metadata
metadata, err := archivalRules.AnalyzeURL(ctx, storeLink.URL)
if err != nil {
return fmt.Errorf("failed to analyze URL: %w", err)
}
// Evaluate rules to determine extractors
extractors, err := deps.RuleEngine.Evaluate(ctx, metadata)
if err != nil {
return fmt.Errorf("failed to evaluate rules: %w", err)
}
// Log which extractors were selected
deps.Logger().Info("Rule engine selected extractors for re-archive", "url", storeLink.URL, "extractorCount", len(extractors), "extractors", extractors)
// If no extractors returned (empty array), skip archiving
if len(extractors) == 0 {
deps.Logger().Info("No extractors selected for re-archive, skipping", "url", storeLink.URL)
return fmt.Errorf("no extractors selected by rules")
}
now := time.Now()
// Create a new archive for re-archival
archive := &model.Archive{
ID: uuid.New().String(),
LinkID: linkID,
UserID: storeLink.UserID,
Status: model.ArchiveStatusPending,
CreatedAt: now,
}
if err := deps.ArchiveStore.Create(ctx, archive); err != nil {
return fmt.Errorf("failed to create archive: %w", err)
}
// Enqueue archive job with extractor keys
extractorKeys := make([]string, len(extractors))
for i, ext := range extractors {
extractorKeys[i] = ext.Key
}
payload := model.ArchiveLinkPayload{
ArchiveID: archive.ID,
LinkID: linkID,
ArchiverKeys: extractorKeys,
}
if err := deps.Queue.Enqueue(ctx, model.JobTypeArchiveLink, payload); err != nil {
return fmt.Errorf("failed to enqueue archive job: %w", err)
}
return nil
}
// GetArchiveFiles retrieves all files for an archive
func (d *ArchiveDomain) GetArchiveFiles(ctx context.Context, archiveID string) ([]*model.ArchiveFile, error) {
deps := d.getDeps()
opts := archivalStore.ArchiveFileListOptions{ArchiveID: archiveID}
files, err := deps.ArchiveFileStore.ListByArchiveID(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to get archive files: %w", err)
}
return files, nil
}
// GetArchiveFile retrieves a specific archive file
func (d *ArchiveDomain) GetArchiveFile(ctx context.Context, fileID string) (*model.ArchiveFile, error) {
deps := d.getDeps()
file, err := deps.ArchiveFileStore.GetByID(ctx, fileID)
if err != nil {
return nil, fmt.Errorf("failed to get archive file: %w", err)
}
return file, nil
}
// DeleteArchive deletes an archive and all its associated files
func (d *ArchiveDomain) DeleteArchive(ctx context.Context, archiveID string) error {
deps := d.getDeps()
// Get the archive first to verify it exists and get linkID
archive, err := deps.ArchiveStore.GetByID(ctx, archiveID)
if err != nil {
return fmt.Errorf("failed to get archive: %w", err)
}
// Get all archive files for this archive
opts := archivalStore.ArchiveFileListOptions{ArchiveID: archiveID}
storeFiles, err := deps.ArchiveFileStore.ListByArchiveID(ctx, opts)
if err != nil {
return fmt.Errorf("failed to list archive files: %w", err)
}
// Delete all storage files
for _, file := range storeFiles {
if err := deps.Storage.Delete(ctx, file.StoragePath); err != nil {
// Log error but continue - file might already be deleted
deps.Logger().Error("Failed to delete storage file", "path", file.StoragePath, "error", err)
}
}
// Delete the archive (cascade will delete archive files from database)
if err := deps.ArchiveStore.Delete(ctx, archiveID); err != nil {
return fmt.Errorf("failed to delete archive: %w", err)
}
// Recalculate total size for the link
if err := deps.LinkStore.RecalculateTotalSize(ctx, archive.LinkID, deps.ArchiveFileStore); err != nil {
// Log error but don't fail - total size update is not critical
deps.Logger().Error("Failed to recalculate total size for link after archive deletion", "linkID", archive.LinkID, "error", err)
}
return nil
}
// ProcessArchive performs the actual archival operation (called by worker)
// If archiverKeys is provided, only those archivers will be used. Otherwise, all enabled archivers are used.
func (d *ArchiveDomain) ProcessArchive(ctx context.Context, archive *model.Archive, link *model.Link, archiverKeys []string) error {
deps := d.getDeps()
// Update status to processing
if err := deps.ArchiveStore.UpdateStatus(ctx, archive.ID, model.ArchiveStatusProcessing, ""); err != nil {
return fmt.Errorf("failed to update archive status: %w", err)
}
// Get archivers to use
var archiversToUse []archiver.Archiver
if len(archiverKeys) > 0 {
// Use specified archivers
archiversToUse = make([]archiver.Archiver, 0, len(archiverKeys))
for _, key := range archiverKeys {
arch, ok := deps.ArchiverMgr.GetArchiver(key)
if !ok {
deps.Logger().Warn("Archiver not found, skipping", "archiverKey", key)
continue
}
if !arch.IsEnabled(ctx) {
deps.Logger().Warn("Archiver is disabled, skipping", "archiverKey", key)
continue
}
archiversToUse = append(archiversToUse, arch)
}
if len(archiversToUse) == 0 {
err := fmt.Errorf("no valid archivers found from provided keys")
if updateErr := deps.ArchiveStore.UpdateStatus(ctx, archive.ID, model.ArchiveStatusFailed, err.Error()); updateErr != nil {
deps.Logger().Error("Failed to update archive status after no archivers found", "archiveID", archive.ID, "error", updateErr)
}
return err
}
} else {
// Use all enabled archivers (backward compatibility)
archiversToUse = deps.ArchiverMgr.ListEnabled(ctx)
if len(archiversToUse) == 0 {
err := fmt.Errorf("no enabled archivers available")
if updateErr := deps.ArchiveStore.UpdateStatus(ctx, archive.ID, model.ArchiveStatusFailed, err.Error()); updateErr != nil {
deps.Logger().Error("Failed to update archive status after no archivers found", "archiveID", archive.ID, "error", updateErr)
}
return err
}
}
// Track results per archiver
type archiverResult struct {
archiverKey string
success bool
title string
files int
error string
}
results := make([]archiverResult, 0, len(archiversToUse))
now := time.Now()
mimeTypes := make([]string, 0)
var firstTitle string
// Process each archiver
for _, arch := range archiversToUse {
archiverKey := arch.Key()
result := archiverResult{
archiverKey: archiverKey,
success: false,
}
// Load and apply archiver configuration
var config map[string]any
storedConfig, err := deps.ArchiverConfigStore.Get(ctx, archiverKey)
if err != nil {
deps.Logger().Warn("Failed to load archiver config, using default", "archiverKey", archiverKey, "error", err)
config = configToMap(arch.GetDefaultConfig())
} else if storedConfig != nil && storedConfig.ConfigJSON != "" {
// Parse stored config JSON
if err := json.Unmarshal([]byte(storedConfig.ConfigJSON), &config); err != nil {
deps.Logger().Warn("Failed to parse stored archiver config, using default", "archiverKey", archiverKey, "error", err)
config = configToMap(arch.GetDefaultConfig())
}
} else {
// No stored config, use default
config = configToMap(arch.GetDefaultConfig())
}
// Apply config to archiver
if err := arch.ApplyConfig(config); err != nil {
deps.Logger().Warn("Failed to apply archiver config, continuing with current settings", "archiverKey", archiverKey, "error", err)
// Continue anyway - archiver may have default settings
}
// Perform the archival
archiveResult, err := arch.Archive(ctx, link, deps.Storage)
if err != nil {
result.error = err.Error()
deps.Logger().Error("Archiver failed", "archiverKey", archiverKey, "archiveID", archive.ID, "error", err)
results = append(results, result)
continue
}
// Archiver succeeded
result.success = true
result.title = archiveResult.Title
result.files = len(archiveResult.Files)
// Save archive files with archiver key
for _, fileInfo := range archiveResult.Files {
archiveFile := &model.ArchiveFile{
ID: uuid.New().String(),
ArchiveID: archive.ID,
ArchiverKey: archiverKey,
Filename: fileInfo.Filename,
MimeType: fileInfo.MimeType,
FileSize: fileInfo.FileSize,
StoragePath: fileInfo.Path,
HashSha256: fileInfo.HashSha256,
CreatedAt: now,
}
if err := deps.ArchiveFileStore.Create(ctx, archiveFile); err != nil {
deps.Logger().Error("Failed to create archive file", "archiveID", archive.ID, "archiverKey", archiverKey, "filename", fileInfo.Filename, "error", err)
result.success = false
result.error = fmt.Sprintf("failed to save file metadata: %v", err)
break // Stop processing files for this archiver
}
// Enqueue content extraction job if MIME type is present
if fileInfo.MimeType != "" {
// Enqueue job to extract content (non-blocking, will be processed asynchronously)
extractPayload := map[string]string{
"file_id": archiveFile.ID,
}
if err := deps.Queue.Enqueue(ctx, model.JobTypeExtractContent, extractPayload); err != nil {
// Log error but don't fail the archive - content extraction is not critical
deps.Logger().Error("Failed to enqueue content extraction job", "fileID", archiveFile.ID, "error", err)
}
}
// Collect MIME types for category determination
if fileInfo.MimeType != "" {
mimeTypes = append(mimeTypes, fileInfo.MimeType)
}
}
// Store title from first successful archiver
if result.success && firstTitle == "" && result.title != "" {
firstTitle = result.title
}
results = append(results, result)
}
// Determine final archive status based on results
var successCount, failureCount int
var errorMessages []string
for _, result := range results {
if result.success {
successCount++
} else {
failureCount++
if result.error != "" {
errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", result.archiverKey, result.error))
}
}
}
var finalStatus model.ArchiveStatus
var errorMessage string
if successCount == 0 {
// All archivers failed
finalStatus = model.ArchiveStatusFailed
errorMessage = fmt.Sprintf("All archivers failed: %s", fmt.Sprintf("%v", errorMessages))
} else if failureCount == 0 {
// All archivers succeeded
finalStatus = model.ArchiveStatusCompleted
} else {
// Some succeeded, some failed - partial status
finalStatus = model.ArchiveStatusPartial
errorMessage = fmt.Sprintf("Some archivers failed: %s", fmt.Sprintf("%v", errorMessages))
}
// Update archive title from first successful archiver
if firstTitle != "" {
if err := deps.ArchiveStore.UpdateTitle(ctx, archive.ID, firstTitle); err != nil {
deps.Logger().Error("Failed to update archive title", "archiveID", archive.ID, "error", err)
// Don't fail the archive for title update failure
}
}
// Update link categories based on file MIME types
if deps.Domains().Categories() != nil && len(mimeTypes) > 0 {
if err := deps.Domains().Categories().UpdateLinkCategories(ctx, link.ID, mimeTypes); err != nil {
// Log error but don't fail the archive - categories are not critical
deps.Logger().Error("Failed to update link categories", "linkID", link.ID, "error", err)
}
}
// Calculate and update total size for the link
if err := deps.LinkStore.RecalculateTotalSize(ctx, link.ID, deps.ArchiveFileStore); err != nil {
// Log error but don't fail the archive - total size update is not critical
deps.Logger().Error("Failed to recalculate total size for link", "linkID", link.ID, "error", err)
}
// Update archive status
if err := deps.ArchiveStore.UpdateStatus(ctx, archive.ID, finalStatus, errorMessage); err != nil {
return fmt.Errorf("failed to update archive status: %w", err)
}
return nil
}