diff --git a/README.md b/README.md index 46d71e7..03a98a8 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,14 @@ Currently, it can download manga from the following websites: | Provider | Website | | ---------- | ------------------------- | | Weebcentral (English) | https://weebcentral.com | +| Mangadex (Multilingual) | https://mangadex.org | | MangaFreak (English)| https://ww3.mangafreak.me | | Anime-Sama (French only) | https://anime-sama.pw | +> [!NOTE] +> For **Mangadex**, goweeb currently only handles manga directly hosted at *mangadex.org*. +> External links are not handled for the moment, as it requires a dedicated scraping logic (I'm working on that). + > [!TIP] > Support for more websites will be added over time. diff --git a/README_FR.md b/README_FR.md index 6e1474a..994b325 100644 --- a/README_FR.md +++ b/README_FR.md @@ -13,8 +13,13 @@ Actuellement, il est possible de télécharger des manga depuis les sites suivan | Provider | Site | | ---------- | ------------------------- | | Weebcentral (Anglais) | https://weebcentral.com | +| Mangadex (Plurilingue) | https://mangadex.org | | MangaFreak (Anglais) | https://ww3.mangafreak.me | -| Anime-Sama (Français uniquement) | https://anime-sama.pw | +| Anime-Sama (Français uniquement) | https://anime-sama.pw | + +> [!NOTE] +> Pour **Mangadex**, seuls les manga directement hebergés sur *mangadex.org* sont disponibles au téléchargement. +> Les manga hebergés sur des liens externes ne sont pour le moment pas gérés puisqu'ils nécessitent l'implémentation d'un scraper dédié (WIP). > [!TIP] > De nouveaux sites seront pris en charge au fil du temps. diff --git a/internal/app/downloader.go b/internal/app/downloader.go index d3447c4..c5eb095 100644 --- a/internal/app/downloader.go +++ b/internal/app/downloader.go @@ -15,50 +15,100 @@ import ( func chapterDigits(maxChapter int) int { d := len(fmt.Sprintf("%d", maxChapter)) + if d < 3 { return 3 } + return d } -func toSelectableManga(in []sourcetypes.SearchResult) []common.SelectableItem { - out := make([]common.SelectableItem, 0, len(in)) +func toSelectableManga( + in []sourcetypes.SearchResult, +) []common.SelectableItem { + out := make( + []common.SelectableItem, + 0, + len(in), + ) + for _, r := range in { - out = append(out, common.SelectableItem{ - Label: r.Title, - Value: r.URL, - }) + out = append( + out, + common.SelectableItem{ + Label: r.Title, + Value: r.URL, + }, + ) } + return out } -// Filters entries based on user options. -func filterEntries(entries []sourcetypes.Entry, opts Options, log *logger.Logger) []sourcetypes.Entry { - if opts.RangeMode == RangeLastN { - n := min(opts.Range[1], len(entries)) - entries = entries[len(entries)-n:] - log.Info("Filtered to the last %d chapters\n", n) - } else if opts.Range != [2]int{} { - var filtered []sourcetypes.Entry - for _, e := range entries { - if opts.RangeMode == RangeOpenEnded && opts.Range[1] == 0 { - if e.Number >= opts.Range[0] { - filtered = append(filtered, e) - } - } else { - if e.Number >= opts.Range[0] && e.Number <= opts.Range[1] { - filtered = append(filtered, e) - } - } - } - entries = filtered - if opts.RangeMode == RangeOpenEnded { - log.Info("Filtered to %d chapters from %d onwards\n", len(entries), opts.Range[0]) - } else { - log.Info("Filtered to %d chapter(s) (%d-%d)\n", len(entries), opts.Range[0], opts.Range[1]) - } +func hasRangeSelection( + selection RangeSelection, +) bool { + return selection.All || + len(selection.Ranges) > 0 +} + +func logAvailableChapters( + entries []sourcetypes.Entry, + log *logger.Logger, +) { + log.Info( + "Found %d chapter(s).\n", + len(entries), + ) + + log.Info( + "Available chapters: %s\n", + FormatAvailableRanges(entries), + ) +} + +func applyRangeSelection( + entries []sourcetypes.Entry, + selection RangeSelection, + log *logger.Logger, +) []sourcetypes.Entry { + filtered := FilterEntriesBySelection( + entries, + selection, + ) + + if len(filtered) == 0 { + return nil } - return entries + + if selection.All { + log.Info( + "Selected all %d available chapter(s).\n", + len(filtered), + ) + + return filtered + } + + log.Info( + "Selected %d chapter(s): %s\n", + len(filtered), + FormatAvailableRanges(filtered), + ) + + return filtered +} + +func imageRequestHeaders( + provider sourcetypes.Provider, +) map[string]string { + configurer, ok := provider.(sourcetypes.ImageRequestConfigurer) + + if !ok { + return nil + } + + return configurer.ImageRequestHeaders() } func downloadEntries( @@ -68,104 +118,259 @@ func downloadEntries( opts Options, log *logger.Logger, ) { - log.Info("Downloading %d chapter(s)...\n", len(entries)) + log.Info( + "Downloading %d chapter(s)...\n", + len(entries), + ) + + requestHeaders := imageRequestHeaders( + provider, + ) homeDir, err := os.UserHomeDir() if err != nil { - log.Error("Failed to get home dir: %v\n", err) + log.Error( + "Failed to get home dir: %v\n", + err, + ) + return } - maxNum := entries[len(entries)-1].Number + maxNum := 0 + + for _, entry := range entries { + if entry.Number > maxNum { + maxNum = entry.Number + } + } + digits := chapterDigits(maxNum) if opts.EbookFriendly { - if err := os.MkdirAll(opts.ScanDir, os.ModePerm); err != nil { - log.Error("Failed to create output dir: %v\n", err) + if err := os.MkdirAll( + opts.ScanDir, + os.ModePerm, + ); err != nil { + log.Error( + "Failed to create output dir: %v\n", + err, + ) + return } } for _, entry := range entries { - chStr := fmt.Sprintf("%d", entry.Number) - log.Info("Downloading %s...\n", entry.Label) - - imageURLs, err := provider.GetPageImageURLs(entry.URL, log) + chStr := fmt.Sprintf( + "%d", + entry.Number, + ) + + log.Info( + "Downloading %s...\n", + entry.Label, + ) + + imageURLs, err := provider.GetPageImageURLs( + entry.URL, + log, + ) if err != nil { - log.Error("Failed to collect image URLs for %s: %v\n", entry.Label, err) + log.Error( + "Failed to collect image URLs for %s: %v\n", + entry.Label, + err, + ) + continue } + if len(imageURLs) == 0 { - log.Error("No image URLs found for %s\n", entry.Label) + log.Error( + "No image URLs found for %s\n", + entry.Label, + ) + continue } if opts.EbookFriendly { prefix := "Chapter" + if work.Kind == sourcetypes.ItemVolume { prefix = "Volume" } - entryDir := filepath.Join(opts.ScanDir, fmt.Sprintf("%s %0*d", prefix, digits, entry.Number)) - if err := fetch.DownloadImages(imageURLs, entryDir, log); err != nil { - log.Error("Failed to download %s: %v\n", entry.Label, err) + entryDir := filepath.Join( + opts.ScanDir, + fmt.Sprintf( + "%s %0*d", + prefix, + digits, + entry.Number, + ), + ) + + if err := fetch.DownloadImagesWithHeaders( + imageURLs, + entryDir, + requestHeaders, + log, + ); err != nil { + log.Error( + "Failed to download %s: %v\n", + entry.Label, + err, + ) + continue } - log.Info("%s saved to %s\n", entry.Label, entryDir) + log.Info( + "%s saved to %s\n", + entry.Label, + entryDir, + ) + continue } - imageDir := filepath.Join(homeDir, "Images", opts.Slug, chStr) - if err := fetch.DownloadImages(imageURLs, imageDir, log); err != nil { - log.Error("Failed to download %s: %v\n", entry.Label, err) + imageDir := filepath.Join( + homeDir, + "Images", + opts.Slug, + chStr, + ) + + if err := fetch.DownloadImagesWithHeaders( + imageURLs, + imageDir, + requestHeaders, + log, + ); err != nil { + log.Error( + "Failed to download %s: %v\n", + entry.Label, + err, + ) + continue } - if err := os.MkdirAll(opts.ScanDir, os.ModePerm); err != nil { - log.Error("Failed to create output dir: %v\n", err) + if err := os.MkdirAll( + opts.ScanDir, + os.ModePerm, + ); err != nil { + log.Error( + "Failed to create output dir: %v\n", + err, + ) + return } - pdfPath := filepath.Join(opts.ScanDir, fmt.Sprintf("%s_%s.pdf", work.Title, chStr)) - if err := convert.ImagesToPDF(imageDir, pdfPath, opts.Cleanup, log); err != nil { - log.Error("Failed to create PDF: %v\n", err) + pdfPath := filepath.Join( + opts.ScanDir, + fmt.Sprintf( + "%s_%s.pdf", + work.Title, + chStr, + ), + ) + + if err := convert.ImagesToPDF( + imageDir, + pdfPath, + opts.Cleanup, + log, + ); err != nil { + log.Error( + "Failed to create PDF: %v\n", + err, + ) + continue } - log.Info("%s saved as %s\n", entry.Label, pdfPath) + log.Info( + "%s saved as %s\n", + entry.Label, + pdfPath, + ) } - if opts.Cleanup && !opts.EbookFriendly { - rootImagesDir := filepath.Join(homeDir, "Images", opts.Slug) - log.Debug("Cleaning up: %s\n", rootImagesDir) + if opts.Cleanup && + !opts.EbookFriendly { + rootImagesDir := filepath.Join( + homeDir, + "Images", + opts.Slug, + ) + + log.Debug( + "Cleaning up: %s\n", + rootImagesDir, + ) + _ = os.RemoveAll(rootImagesDir) } } -func Run(opts Options, log *logger.Logger) { - provider, err := source.New(opts.Source, opts.CustomDomain) +func Run( + opts Options, + log *logger.Logger, +) { + provider, err := source.New( + opts.Source, + opts.CustomDomain, + ) if err != nil { - log.Error("Failed to initialize source provider: %v\n", err) + log.Error( + "Failed to initialize source provider: %v\n", + err, + ) + return } - log.Info("Searching for manga: %s\n", opts.Slug) + log.Info( + "Searching for manga: %s\n", + opts.Slug, + ) - searchResults, err := provider.Search(opts.Slug, log) + searchResults, err := provider.Search( + opts.Slug, + log, + ) if err != nil { - log.Error("Failed to search catalog: %v\n", err) + log.Error( + "Failed to search catalog: %v\n", + err, + ) + return } + if len(searchResults) == 0 { - log.Error("Manga '%s' not found in catalog\n", opts.Slug) + log.Error( + "Manga '%s' not found in catalog\n", + opts.Slug, + ) + return } if len(searchResults) == 1 { - log.Info("Found: %s\n", searchResults[0].Title) + log.Info( + "Found: %s\n", + searchResults[0].Title, + ) } else { - log.Info("Found %d results.\n", len(searchResults)) + log.Info( + "Found %d results.\n", + len(searchResults), + ) } mangaURL, err := common.PromptUserToSelect( @@ -175,21 +380,40 @@ func Run(opts Options, log *logger.Logger) { log, ) if err != nil { - log.Error("Failed to select manga: %v\n", err) + log.Error( + "Failed to select manga: %v\n", + err, + ) + return } + if mangaURL == "" { - log.Warn("No manga selected. Exiting.") + log.Warn( + "No manga selected. Exiting.", + ) + return } - scanPaths, err := provider.ListScanPaths(mangaURL, log) + scanPaths, err := provider.ListScanPaths( + mangaURL, + log, + ) if err != nil { - log.Error("Failed to list scan paths: %v\n", err) + log.Error( + "Failed to list scan paths: %v\n", + err, + ) + return } + if len(scanPaths) == 0 { - log.Warn("No scan versions found.") + log.Warn( + "No scan versions found.", + ) + return } @@ -200,72 +424,167 @@ func Run(opts Options, log *logger.Logger) { log, ) if err != nil { - log.Error("Failed to select scan path: %v\n", err) + log.Error( + "Failed to select scan path: %v\n", + err, + ) + return } + if scanPath == "" { - log.Warn("No scan version selected. Exiting.") + log.Warn( + "No scan version selected. Exiting.", + ) + return } - work, entries, err := provider.ListEntries(mangaURL, scanPath, log) + work, entries, err := provider.ListEntries( + mangaURL, + scanPath, + log, + ) if err != nil { - log.Error("Failed to list entries: %v\n", err) + log.Error( + "Failed to list entries: %v\n", + err, + ) + return } + if len(entries) == 0 { - log.Warn("No entries found.") + log.Warn( + "No entries found.", + ) + return } - if !opts.All && opts.RangeMode == RangeNone { - chRange, chMode, all, err := PromptChapterRange(len(entries), log) + if !hasRangeSelection(opts.Selection) { + selection, err := PromptChapterRange( + entries, + log, + ) if err != nil { - log.Error("Failed to read chapter range: %v\n", err) + log.Error( + "Failed to read chapter range: %v\n", + err, + ) + return } - opts.All = all - opts.Range = chRange - opts.RangeMode = chMode + + opts.Selection = selection + } else { + // When --range or --all was supplied on the CLI, + // there is no interactive prompt, so still show the + // user what chapters are actually available. + logAvailableChapters( + entries, + log, + ) } - entries = filterEntries(entries, opts, log) + entries = applyRangeSelection( + entries, + opts.Selection, + log, + ) + if len(entries) == 0 { - log.Warn("No chapters found in range.") + log.Warn( + "No available chapters match the requested selection.", + ) + return } - downloadEntries(provider, work, entries, opts, log) + downloadEntries( + provider, + work, + entries, + opts, + log, + ) } -func RunWithWorkflow(opts Options, log *logger.Logger) { - if !opts.All && opts.RangeMode == 0 { - log.Warn("Please enter a valid range to download chapters.") +func RunWithWorkflow( + opts Options, + log *logger.Logger, +) { + if !hasRangeSelection(opts.Selection) { + log.Warn( + "Please enter a valid chapter selection.", + ) + return } - provider, err := source.New(opts.Source, opts.CustomDomain) + provider, err := source.New( + opts.Source, + opts.CustomDomain, + ) if err != nil { - log.Error("Failed to initialize source provider: %v\n", err) + log.Error( + "Failed to initialize source provider: %v\n", + err, + ) + return } - work, entries, err := provider.ListEntries(opts.MangaURL, opts.ScanPath, log) + work, entries, err := provider.ListEntries( + opts.MangaURL, + opts.ScanPath, + log, + ) if err != nil { - log.Error("Failed to list entries: %v\n", err) + log.Error( + "Failed to list entries: %v\n", + err, + ) + return } + if len(entries) == 0 { - log.Warn("No entries found.") + log.Warn( + "No entries found.", + ) + return } - entries = filterEntries(entries, opts, log) + logAvailableChapters( + entries, + log, + ) + + entries = applyRangeSelection( + entries, + opts.Selection, + log, + ) + if len(entries) == 0 { - log.Warn("No chapters found in range.") + log.Warn( + "No available chapters match the requested selection.", + ) + return } - downloadEntries(provider, work, entries, opts, log) - log.Info("Finished downloading") + downloadEntries( + provider, + work, + entries, + opts, + log, + ) + + log.Info( + "Finished downloading\n", + ) } diff --git a/internal/app/options.go b/internal/app/options.go index a974041..ca0f9e9 100644 --- a/internal/app/options.go +++ b/internal/app/options.go @@ -3,17 +3,18 @@ package app import ( "flag" "fmt" - "github.com/EliasLd/goweeb/internal/source" "log" "os" "sort" "strings" + + "github.com/EliasLd/goweeb/internal/source" ) type RangeMode int const ( - RangeNone = iota + RangeNone RangeMode = iota RangeNormal RangeOpenEnded RangeLastN @@ -37,15 +38,13 @@ func supportedProvidersList() string { return strings.Join(names, ", ") } -// Holds parsed CLI arguments +// Holds parsed CLI arguments. type Options struct { Slug string - All bool - Range [2]int // [0] = start, [1] = end (0 means open-ended) - RangeMode RangeMode + Selection RangeSelection ScanDir string Cleanup bool - CustomDomain string // custom domain override + CustomDomain string Debug bool EbookFriendly bool Source string @@ -54,149 +53,315 @@ type Options struct { ScanPath string } -func ParseFlags() Options { - // Define flags - allFlag := flag.Bool("all", false, "Download all available chapters") - allShort := flag.Bool("a", false, "Shortand for --all)") - - sourceFlag := flag.String("source", "", "Source provider...") - - rangeFlag := flag.String("range", "", "Range of chapters to download, e.g., 10-77, 14-") - rangeShort := flag.String("r", "", "Shorthand for --range") - - var scanDir string - flag.StringVar(&scanDir, "scan-dir", "scan", "Directory to save the generated PDF files") - flag.StringVar(&scanDir, "d", "pdf", "Shorthand for --scan-dir") - - ebookFlag := flag.Bool("ebook-friendly", false, "Save chapters as image folders, compatible with Kindle Comic Converter (no pdf output)") - - keepImagesFlag := flag.Bool("keep-images", false, "Keep images after PDF creation") - keepImagesShort := flag.Bool("k", false, "Shorthand for --keep-images") - - var customDomain string - flag.StringVar(&customDomain, "domain", "", "Override the provider's default domain") - flag.StringVar(&customDomain, "u", "", "Shorthand for --domain") +func configureUsage() { + flag.Usage = func() { + out := flag.CommandLine.Output() + + fmt.Fprintln(out, "goweeb - Fast and lightweight manga downloader") + fmt.Fprintln(out) + + fmt.Fprintln(out, "Usage:") + fmt.Fprintln(out, ` goweeb [options] ""`) + fmt.Fprintln(out) + + fmt.Fprintln(out, "Required:") + fmt.Fprintln(out, " --source ") + fmt.Fprintln(out, " Source provider") + fmt.Fprintf( + out, + " Supported: %s\n", + supportedProvidersList(), + ) + fmt.Fprintln(out) + + fmt.Fprintln(out, "Chapter selection:") + fmt.Fprintln(out, " --all") + fmt.Fprintln(out, " -a") + fmt.Fprintln(out, " Download all available chapters") + fmt.Fprintln(out) + + fmt.Fprintln(out, " --range ") + fmt.Fprintln(out, " -r ") + fmt.Fprintln(out, " Select chapters to download") + fmt.Fprintln(out, " Examples:") + fmt.Fprintln(out, " 10 chapter 10") + fmt.Fprintln(out, " 1-10 chapters 1 through 10") + fmt.Fprintln(out, " 10- chapter 10 onwards") + fmt.Fprintln(out, " -10 last 10 available chapters") + fmt.Fprintln(out, " 1-10,20-30 multiple ranges") + fmt.Fprintln(out, " 1,5,10 multiple individual chapters") + fmt.Fprintln(out) + + fmt.Fprintln(out, "Output:") + fmt.Fprintln(out, " --scan-dir ") + fmt.Fprintln(out, " -d ") + fmt.Fprintln(out, " Output directory (default: scan)") + fmt.Fprintln(out) + + fmt.Fprintln(out, " --ebook-friendly") + fmt.Fprintln(out, " Save chapters as image folders instead of PDF files") + fmt.Fprintln(out) + + fmt.Fprintln(out, " --keep-images") + fmt.Fprintln(out, " -k") + fmt.Fprintln(out, " Keep downloaded images after PDF creation") + fmt.Fprintln(out) + + fmt.Fprintln(out, "Provider options:") + fmt.Fprintln(out, " --domain ") + fmt.Fprintln(out, " -u ") + fmt.Fprintln(out, " Override the provider's default domain") + fmt.Fprintln(out) + + fmt.Fprintln(out, "Other:") + fmt.Fprintln(out, " --debug") + fmt.Fprintln(out, " Enable verbose debug logging") + fmt.Fprintln(out) + + fmt.Fprintln(out, " --help") + fmt.Fprintln(out, " -h") + fmt.Fprintln(out, " Show this help message") + } +} - debugFlag := flag.Bool("debug", false, "Enable verbose debug logging") +func ParseFlags() Options { + configureUsage() + + var ( + all bool + sourceName string + rangeStr string + scanDir string + ebookFriendly bool + keepImages bool + customDomain string + debug bool + ) + + // Chapter selection. + flag.BoolVar( + &all, + "all", + false, + "Download all available chapters", + ) + flag.BoolVar( + &all, + "a", + false, + "Shorthand for --all", + ) + + flag.StringVar( + &rangeStr, + "range", + "", + "Chapter selection", + ) + flag.StringVar( + &rangeStr, + "r", + "", + "Shorthand for --range", + ) + + // Provider. + flag.StringVar( + &sourceName, + "source", + "", + "Source provider", + ) + + flag.StringVar( + &customDomain, + "domain", + "", + "Override the provider's default domain", + ) + flag.StringVar( + &customDomain, + "u", + "", + "Shorthand for --domain", + ) + + // Output. + flag.StringVar( + &scanDir, + "scan-dir", + "scan", + "Directory to save generated files", + ) + flag.StringVar( + &scanDir, + "d", + "scan", + "Shorthand for --scan-dir", + ) + + flag.BoolVar( + &ebookFriendly, + "ebook-friendly", + false, + "Save chapters as image folders instead of PDF files", + ) + + flag.BoolVar( + &keepImages, + "keep-images", + false, + "Keep images after PDF creation", + ) + flag.BoolVar( + &keepImages, + "k", + false, + "Shorthand for --keep-images", + ) + + // Misc. + flag.BoolVar( + &debug, + "debug", + false, + "Enable verbose debug logging", + ) flag.Parse() - // Expecting slug (manga title) as a positional argument args := flag.Args() + if len(args) < 1 { - fmt.Println("Usage: scan-scraper [options] ") - flag.PrintDefaults() - os.Exit(1) + fmt.Fprintln( + os.Stderr, + "Missing manga title.", + ) + fmt.Fprintln(os.Stderr) + + flag.Usage() + os.Exit(2) } - slug := args[0] - // Resolve final values - all := *allFlag || *allShort - - sourceName := strings.ToLower(strings.TrimSpace(*sourceFlag)) + // Allows both: + // + // goweeb ... "one piece" + // + // and: + // + // goweeb ... one piece + // + // as long as the positional arguments come after the flags. + slug := strings.TrimSpace( + strings.Join(args, " "), + ) + + sourceName = strings.ToLower( + strings.TrimSpace(sourceName), + ) if sourceName == "" { - fmt.Println("Missing required option: --source") - fmt.Printf("Supported providers: %s\n", supportedProvidersList()) - os.Exit(1) + fmt.Fprintln( + os.Stderr, + "Missing required option: --source", + ) + + fmt.Fprintf( + os.Stderr, + "Supported providers: %s\n", + supportedProvidersList(), + ) + + os.Exit(2) } if !isValidProvider(sourceName) { - fmt.Printf("Unsupported source provider: %q\n", sourceName) - fmt.Printf("Supported providers: %s\n", supportedProvidersList()) - os.Exit(1) + fmt.Fprintf( + os.Stderr, + "Unsupported source provider: %q\n", + sourceName, + ) + + fmt.Fprintf( + os.Stderr, + "Supported providers: %s\n", + supportedProvidersList(), + ) + + os.Exit(2) } - dir := scanDir - keepImages := *keepImagesFlag || *keepImagesShort - domain := customDomain - debug := *debugFlag - ebook := *ebookFlag - - // Normalize domain (remove trailing slash, ensure https://) - if domain != "" { - domain = strings.TrimSuffix(domain, "/") - if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { - domain = "https://" + domain - } + rangeStr = strings.TrimSpace(rangeStr) + + if all && rangeStr != "" { + fmt.Fprintln( + os.Stderr, + "--all and --range cannot be used together", + ) + + os.Exit(2) } - // Parse chapters range - rangeStr := *rangeFlag - if *rangeShort != "" { - rangeStr = *rangeShort + var selection RangeSelection + + if all { + selection.All = true + } else if rangeStr != "" { + parsedSelection, err := ParseRangeExpression( + rangeStr, + ) + if err != nil { + fmt.Fprintf( + os.Stderr, + "Invalid chapter selection: %v\n", + err, + ) + + os.Exit(2) + } + + selection = parsedSelection } - var chapterRange [2]int - var rangeMode RangeMode = RangeNone - - if rangeStr != "" { - // Handle last N chapters - if strings.HasPrefix(rangeStr, "-") && len(rangeStr) > 1 { - var nLast int - n, err := fmt.Sscanf(rangeStr, "-%d", &nLast) - if err != nil || n != 1 || nLast <= 0 { - fmt.Printf("Invalid range format: %s. Use format , - or -\n", rangeStr) - os.Exit(1) - } - chapterRange[0] = 0 // useles for LastN format, filled for compatiblity - chapterRange[1] = nLast - rangeMode = RangeLastN - // Handle open-ended range like "10-" - } else if strings.HasSuffix(rangeStr, "-") { - var start int - trimmed := strings.TrimSuffix(rangeStr, "-") - n, err := fmt.Sscanf(trimmed, "%d", &start) - if err != nil || n != 1 { - fmt.Printf("Invalid range format: %s. Use format: - or -\n", rangeStr) - os.Exit(1) - } - chapterRange[0] = start - chapterRange[1] = 0 // 0 means open-ended - rangeMode = RangeOpenEnded - // Normal range like "10-20" - } else { - var start, end int - n, err := fmt.Sscanf(rangeStr, "%d-%d", &start, &end) - if err == nil && n == 2 && start <= end { - chapterRange[0] = start - chapterRange[1] = end - rangeMode = RangeNormal - } else { - // If only one chapter provided - var solo int - n, err := fmt.Sscanf(rangeStr, "%d", &solo) - if err == nil && n == 1 { - chapterRange[0] = solo - chapterRange[1] = solo - rangeMode = RangeNormal - } else { - fmt.Printf("Invalid range format: %s. Use format: -, -, - or \n", rangeStr) - os.Exit(1) - } - } + customDomain = strings.TrimSpace( + customDomain, + ) + + if customDomain != "" { + customDomain = strings.TrimSuffix( + customDomain, + "/", + ) + + if !strings.HasPrefix(customDomain, "http://") && + !strings.HasPrefix(customDomain, "https://") { + customDomain = "https://" + customDomain } } - // Create scanDir if it doesn't exist - if _, err := os.Stat(dir); os.IsNotExist(err) { + if _, err := os.Stat(scanDir); os.IsNotExist(err) { const defaultDirPerm = 0755 - err := os.MkdirAll(dir, defaultDirPerm) - if err != nil { - log.Fatalf("Failed to create scan-dir (%s): %v", dir, err) + + if err := os.MkdirAll( + scanDir, + defaultDirPerm, + ); err != nil { + log.Fatalf( + "Failed to create scan-dir (%s): %v", + scanDir, + err, + ) } } return Options{ Slug: slug, - All: all, + Selection: selection, Source: sourceName, - Range: chapterRange, - ScanDir: dir, - RangeMode: rangeMode, + ScanDir: scanDir, Cleanup: !keepImages, - CustomDomain: domain, + CustomDomain: customDomain, Debug: debug, - EbookFriendly: ebook, + EbookFriendly: ebookFriendly, } } diff --git a/internal/app/range_available.go b/internal/app/range_available.go new file mode 100644 index 0000000..fc89c9f --- /dev/null +++ b/internal/app/range_available.go @@ -0,0 +1,98 @@ +package app + +import ( + "fmt" + "sort" + "strings" + + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" +) + +type AvailableRange struct { + Start int + End int +} + +func AvailableChapterRanges( + entries []sourcetypes.Entry, +) []AvailableRange { + if len(entries) == 0 { + return nil + } + + numbers := make([]int, 0, len(entries)) + seen := make(map[int]struct{}) + + for _, entry := range entries { + if _, exists := seen[entry.Number]; exists { + continue + } + + seen[entry.Number] = struct{}{} + numbers = append(numbers, entry.Number) + } + + sort.Ints(numbers) + + ranges := make([]AvailableRange, 0) + + start := numbers[0] + previous := numbers[0] + + for _, number := range numbers[1:] { + if number == previous+1 { + previous = number + continue + } + + ranges = append( + ranges, + AvailableRange{ + Start: start, + End: previous, + }, + ) + + start = number + previous = number + } + + ranges = append( + ranges, + AvailableRange{ + Start: start, + End: previous, + }, + ) + + return ranges +} + +// Joints ranges of available chapters/volumes in a single string +// according to the format: +// n_1-m_1,n_2-m_2,...,n_n-m_n +func FormatAvailableRanges( + entries []sourcetypes.Entry, +) string { + ranges := AvailableChapterRanges(entries) + + parts := make([]string, 0, len(ranges)) + + for _, r := range ranges { + if r.Start == r.End { + parts = append( + parts, + fmt.Sprintf("%d", r.Start), + ) + + continue + } + + parts = append( + parts, + fmt.Sprintf("%d-%d", r.Start, r.End), + ) + } + + return strings.Join(parts, ", ") +} diff --git a/internal/app/range_filter.go b/internal/app/range_filter.go new file mode 100644 index 0000000..3cba38e --- /dev/null +++ b/internal/app/range_filter.go @@ -0,0 +1,71 @@ +package app + +import ( + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" +) + +func FilterEntriesBySelection( + entries []sourcetypes.Entry, + selection RangeSelection, +) []sourcetypes.Entry { + if selection.All { + return entries + } + + if len(selection.Ranges) == 0 { + return nil + } + + // Last N is intentionally only allowed as a standalone selector. + if selection.Ranges[0].Mode == RangeLastN { + n := selection.Ranges[0].Range[1] + + if n > len(entries) { + n = len(entries) + } + + return entries[len(entries)-n:] + } + + selected := make( + []sourcetypes.Entry, + 0, + len(entries), + ) + + seen := make(map[int]struct{}) + + for _, entry := range entries { + for _, requested := range selection.Ranges { + start := requested.Range[0] + end := requested.Range[1] + + matches := false + + switch requested.Mode { + case RangeOpenEnded: + matches = entry.Number >= start + + case RangeNormal: + matches = + entry.Number >= start && + entry.Number <= end + } + + if !matches { + continue + } + + if _, exists := seen[entry.Number]; exists { + break + } + + seen[entry.Number] = struct{}{} + selected = append(selected, entry) + + break + } + } + + return selected +} diff --git a/internal/app/range_parse.go b/internal/app/range_parse.go index 2328054..d4d0d99 100644 --- a/internal/app/range_parse.go +++ b/internal/app/range_parse.go @@ -5,6 +5,16 @@ import ( "strings" ) +type RangeSpec struct { + Range [2]int + Mode RangeMode +} + +type RangeSelection struct { + All bool + Ranges []RangeSpec +} + // Parses: // n, n-m, n-, -n func ParseRangeString(rangeStr string) ([2]int, RangeMode, error) { @@ -65,3 +75,54 @@ func ParseRangeString(rangeStr string) ([2]int, RangeMode, error) { return chapterRange, rangeMode, fmt.Errorf("invalid range format: %s. Use 1-10, 10, -10, 10- or all", rangeStr) } + +func ParseRangeExpression(input string) (RangeSelection, error) { + var selection RangeSelection + + input = strings.TrimSpace(input) + if input == "" { + return selection, fmt.Errorf("invalid range format: empty input") + } + + if strings.EqualFold(input, "all") { + selection.All = true + return selection, nil + } + + parts := strings.Split(input, ",") + + for _, part := range parts { + part = strings.TrimSpace(part) + + if part == "" { + return selection, fmt.Errorf( + "invalid range expression: %s", + input, + ) + } + + chapterRange, mode, err := ParseRangeString(part) + if err != nil { + return selection, err + } + + // "-10" means "last 10 available chapters". + // Mixing that with explicit numerical ranges would make + // the semantics unnecessarily confusing. + if mode == RangeLastN && len(parts) > 1 { + return selection, fmt.Errorf( + "last-N syntax cannot be combined with other ranges", + ) + } + + selection.Ranges = append( + selection.Ranges, + RangeSpec{ + Range: chapterRange, + Mode: mode, + }, + ) + } + + return selection, nil +} diff --git a/internal/app/range_prompt.go b/internal/app/range_prompt.go index 1a56927..299fc35 100644 --- a/internal/app/range_prompt.go +++ b/internal/app/range_prompt.go @@ -7,49 +7,74 @@ import ( "strings" "github.com/EliasLd/goweeb/internal/logger" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" ) -// Asks user to choose chapters after entries are discovered. -func PromptChapterRange(total int, log *logger.Logger) ([2]int, RangeMode, bool, error) { - var chapterRange [2]int - var rangeMode RangeMode = RangeNone - all := false +func PromptChapterRange( + entries []sourcetypes.Entry, + log *logger.Logger, +) (RangeSelection, error) { + var selection RangeSelection reader := bufio.NewReader(os.Stdin) - log.Info("Found %d chapters.\n", total) + log.Info( + "Found %d chapter(s).\n", + len(entries), + ) + + log.Info( + "Available chapters: %s\n", + FormatAvailableRanges(entries), + ) + log.Info("Select chapters to download:\n") - log.Info(" - all : all chapters\n") - log.Info(" - 10 : only chapter 10\n") - log.Info(" - 1-10 : chapters 1 to 10\n") - log.Info(" - 10- : chapter 10 to last\n") - log.Info(" - -10 : last 10 chapters\n") + log.Info(" - all : all available chapters\n") + log.Info(" - 10 : only chapter 10\n") + log.Info(" - 1-10 : chapters 1 to 10\n") + log.Info(" - 10- : chapter 10 onwards\n") + log.Info(" - -10 : last 10 available chapters\n") + log.Info(" - 1-10,20-30 : multiple ranges\n") for { log.Info("Enter range: ") input, err := reader.ReadString('\n') if err != nil { - return chapterRange, rangeMode, all, fmt.Errorf("failed to read input: %w", err) + return selection, fmt.Errorf( + "failed to read input: %w", + err, + ) } input = strings.TrimSpace(input) + if input == "" { - log.Warn("Empty input. Please enter a valid range.\n") + log.Warn( + "Empty input. Please enter a valid range.\n", + ) continue } - if strings.EqualFold(input, "all") { - all = true - return chapterRange, rangeMode, all, nil - } - - parsedRange, parsedMode, err := ParseRangeString(input) + parsed, err := ParseRangeExpression(input) if err != nil { log.Warn("%v\n", err) continue } - return parsedRange, parsedMode, all, nil + matched := FilterEntriesBySelection( + entries, + parsed, + ) + + if len(matched) == 0 { + log.Warn( + "No available chapters match this selection.\n", + ) + + continue + } + + return parsed, nil } } diff --git a/internal/fetch/download.go b/internal/fetch/download.go index fd40cb4..986ae45 100644 --- a/internal/fetch/download.go +++ b/internal/fetch/download.go @@ -56,8 +56,27 @@ func CollectSequentialJPGURLs(chapterURL string, log *logger.Logger) ([]string, return urls, nil } -// Downloads a list of image URLs into destDir as 001.jpg, 002.jpg... -func DownloadImages(imageURLs []string, destDir string, log *logger.Logger) error { +// Downloads a list of image URLs using the default request headers. +func DownloadImages( + imageURLs []string, + destDir string, + log *logger.Logger, +) error { + return DownloadImagesWithHeaders( + imageURLs, + destDir, + nil, + log, + ) +} + +// Downloads a list of image URLs with optional custom request headers. +func DownloadImagesWithHeaders( + imageURLs []string, + destDir string, + headers map[string]string, + log *logger.Logger, +) error { if len(imageURLs) == 0 { return fmt.Errorf("empty image URL list") } @@ -67,39 +86,86 @@ func DownloadImages(imageURLs []string, destDir string, log *logger.Logger) erro return fmt.Errorf("failed to create destDir: %w", err) } - client := &http.Client{Timeout: 30 * time.Second} + client := &http.Client{ + Timeout: 30 * time.Second, + } for i, imgURL := range imageURLs { - req, err := http.NewRequest("GET", imgURL, nil) + req, err := http.NewRequest( + http.MethodGet, + imgURL, + nil, + ) if err != nil { - return fmt.Errorf("failed to create request: %w", err) + return fmt.Errorf( + "failed to create request: %w", + err, + ) + } + + // Default behaviour used by existing providers. + req.Header.Set( + "User-Agent", + "Mozilla/5.0", + ) + + // Provider-specific headers override defaults when present. + for key, value := range headers { + req.Header.Set(key, value) } - req.Header.Set("User-Agent", "Mozilla/5.0") resp, err := client.Do(req) if err != nil { - return fmt.Errorf("HTTP GET failed: %w", err) + return fmt.Errorf( + "HTTP GET failed: %w", + err, + ) } + if resp.StatusCode != http.StatusOK { resp.Body.Close() - return fmt.Errorf("unexpected status %d for %s", resp.StatusCode, imgURL) + + return fmt.Errorf( + "unexpected status %d for %s", + resp.StatusCode, + imgURL, + ) } - imgPath := filepath.Join(destDir, fmt.Sprintf("%03d.jpg", i+1)) + imgPath := filepath.Join( + destDir, + fmt.Sprintf("%03d.jpg", i+1), + ) + outFile, err := os.Create(imgPath) if err != nil { resp.Body.Close() - return fmt.Errorf("failed to create image file: %w", err) + + return fmt.Errorf( + "failed to create image file: %w", + err, + ) } - _, err = io.Copy(outFile, resp.Body) + _, copyErr := io.Copy( + outFile, + resp.Body, + ) + outFile.Close() resp.Body.Close() - if err != nil { - return fmt.Errorf("failed to save image: %w", err) + + if copyErr != nil { + return fmt.Errorf( + "failed to save image: %w", + copyErr, + ) } - log.Debug("Downloaded page %d\n", i+1) + log.Debug( + "Downloaded page %d\n", + i+1, + ) } return nil diff --git a/internal/source/mangadex/languages.go b/internal/source/mangadex/languages.go new file mode 100644 index 0000000..972e54c --- /dev/null +++ b/internal/source/mangadex/languages.go @@ -0,0 +1,41 @@ +package mangadex + +import "fmt" + +var languageNames = map[string]string{ + "ar": "Arabic", + "bn": "Bengali", + "cs": "Czech", + "de": "German", + "en": "English", + "es": "Spanish", + "es-la": "Spanish (Latin America)", + "fr": "French", + "hu": "Hungarian", + "id": "Indonesian", + "it": "Italian", + "ja": "Japanese", + "ja-ro": "Japanese (Romanized)", + "kk": "Kazakh", + "ko": "Korean", + "ko-ro": "Korean (Romanized)", + "pl": "Polish", + "pt": "Portuguese", + "pt-br": "Portuguese (Brazil)", + "ru": "Russian", + "th": "Thai", + "tr": "Turkish", + "uk": "Ukrainian", + "uz": "Uzbek", + "vi": "Vietnamese", + "zh": "Chinese (Simplified)", + "zh-hk": "Chinese (Traditional)", +} + +func languageLabel(code string) string { + if name, ok := languageNames[code]; ok { + return fmt.Sprintf("%s (%s)", name, code) + } + + return code +} diff --git a/internal/source/mangadex/provider.go b/internal/source/mangadex/provider.go new file mode 100644 index 0000000..09a44cb --- /dev/null +++ b/internal/source/mangadex/provider.go @@ -0,0 +1,175 @@ +package mangadex + +import ( + "fmt" + "sort" + "strings" + + "github.com/EliasLd/goweeb/internal/logger" + "github.com/EliasLd/goweeb/internal/source/common" + mangadexscraper "github.com/EliasLd/goweeb/internal/source/mangadex/scraper" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" +) + +const defaultDomain = "https://api.mangadex.org" + +type Provider struct { + domain string +} + +func New(customDomain string) *Provider { + domain := customDomain + + if domain == "" { + domain = defaultDomain + } + + return &Provider{ + domain: strings.TrimSuffix(domain, "/"), + } +} + +func (p *Provider) Name() string { + return "mangadex" +} + +func (p *Provider) Search( + query string, + log *logger.Logger, +) ([]sourcetypes.SearchResult, error) { + results, err := mangadexscraper.SearchCatalog( + p.domain, + query, + log, + ) + if err != nil { + return nil, err + } + + out := make( + []sourcetypes.SearchResult, + 0, + len(results), + ) + + for _, result := range results { + out = append( + out, + sourcetypes.SearchResult{ + Title: result.Title, + URL: result.URL, + }, + ) + } + + return out, nil +} + +func (p *Provider) ListScanPaths( + workURL string, + log *logger.Logger, +) ([]common.SelectableItem, error) { + languages, err := mangadexscraper.GetAvailableLanguages( + workURL, + log, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get MangaDex available languages: %w", + err, + ) + } + + items := make( + []common.SelectableItem, + 0, + len(languages), + ) + + for _, language := range languages { + items = append( + items, + common.SelectableItem{ + Label: languageLabel(language), + Value: language, + }, + ) + } + + sort.Slice(items, func(i, j int) bool { + return items[i].Label < items[j].Label + }) + + return items, nil +} + +func (p *Provider) ListEntries( + workURL string, + scanPath string, + log *logger.Logger, +) ( + sourcetypes.Work, + []sourcetypes.Entry, + error, +) { + scanInfo, err := mangadexscraper.GetScanInfo( + p.domain, + workURL, + scanPath, + log, + ) + if err != nil { + return sourcetypes.Work{}, nil, fmt.Errorf( + "failed to get MangaDex scan info: %w", + err, + ) + } + + entries := make( + []sourcetypes.Entry, + 0, + len(scanInfo.Chapters), + ) + + for _, chapter := range scanInfo.Chapters { + entries = append( + entries, + sourcetypes.Entry{ + Number: chapter.Number, + Label: chapter.Label, + URL: chapter.URL, + }, + ) + } + + return sourcetypes.Work{ + Title: scanInfo.MangaName, + Kind: sourcetypes.ItemChapter, + }, entries, nil +} + +func (p *Provider) GetPageImageURLs( + entryURL string, + log *logger.Logger, +) ([]string, error) { + imageURLs, err := mangadexscraper.GetPageImageURLs( + p.domain, + entryURL, + log, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to get MangaDex page images: %w", + err, + ) + } + + return imageURLs, nil +} + +func (p *Provider) ImageRequestHeaders() map[string]string { + return map[string]string{ + "User-Agent": "goweeb (+https://github.com/EliasLd/goweeb)", + "Accept": "*/*", + } +} diff --git a/internal/source/mangadex/scraper/catalog.go b/internal/source/mangadex/scraper/catalog.go new file mode 100644 index 0000000..654021a --- /dev/null +++ b/internal/source/mangadex/scraper/catalog.go @@ -0,0 +1,191 @@ +package mangadexscraper + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" +) + +type MangaResult struct { + Title string + URL string +} + +type mangaSearchResponse struct { + Result string `json:"result"` + Data []mangaEntry `json:"data"` +} + +type mangaEntry struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes mangaAttributes `json:"attributes"` +} + +type mangaAttributes struct { + Title map[string]string `json:"title"` +} + +func SearchCatalog( + domain string, + query string, + log *logger.Logger, +) ([]MangaResult, error) { + base := strings.TrimSuffix(domain, "/") + + u, err := url.Parse(base + "/manga") + if err != nil { + return nil, fmt.Errorf( + "failed to build MangaDex search URL: %w", + err, + ) + } + + q := u.Query() + q.Set("title", query) + q.Set("limit", "50") + q.Set("order[relevance]", "desc") + + u.RawQuery = q.Encode() + + searchURL := u.String() + + log.Debug( + "Searching MangaDex catalog: %s\n", + searchURL, + ) + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + searchURL, + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create MangaDex search request: %w", + err, + ) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "goweeb") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to search MangaDex catalog: %w", + err, + ) + } + defer resp.Body.Close() + + log.Debug( + "MangaDex catalog response status: %d\n", + resp.StatusCode, + ) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "MangaDex catalog returned status: %d", + resp.StatusCode, + ) + } + + var response mangaSearchResponse + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf( + "failed to decode MangaDex response: %w", + err, + ) + } + + if response.Result != "ok" { + return nil, fmt.Errorf( + "MangaDex returned unexpected result: %s", + response.Result, + ) + } + + results := make([]MangaResult, 0, len(response.Data)) + + for _, manga := range response.Data { + if manga.ID == "" { + continue + } + + title := selectMangaTitle(manga.Attributes.Title) + + if title == "" { + continue + } + + results = append(results, MangaResult{ + Title: title, + + // Keep an API URL as the work URL. + // Later methods can directly extract the UUID from it. + URL: fmt.Sprintf( + "%s/manga/%s", + base, + manga.ID, + ), + }) + } + + return results, nil +} + +func selectMangaTitle( + titles map[string]string, +) string { + if len(titles) == 0 { + return "" + } + + // Prefer common readable title variants. + preferredLanguages := []string{ + "en", + "fr", + "es", + "ja-ro", + "ja", + "ko", + "ko-ro", + "zh", + "zh-hk", + } + + for _, language := range preferredLanguages { + if title := strings.TrimSpace(titles[language]); title != "" { + return title + } + } + + // Deterministic fallback instead of relying on Go's map order. + keys := make([]string, 0, len(titles)) + + for language := range titles { + keys = append(keys, language) + } + + sort.Strings(keys) + + for _, language := range keys { + if title := strings.TrimSpace(titles[language]); title != "" { + return title + } + } + + return "" +} diff --git a/internal/source/mangadex/scraper/chapters.go b/internal/source/mangadex/scraper/chapters.go new file mode 100644 index 0000000..42180fe --- /dev/null +++ b/internal/source/mangadex/scraper/chapters.go @@ -0,0 +1,552 @@ +package mangadexscraper + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" +) + +const chapterFeedPageSize = 100 + +type ScanInfo struct { + MangaName string + Chapters []ChapterInfo +} + +type ChapterInfo struct { + Number int + Label string + URL string + ID string + PublishedAt string +} + +type chapterFeedResponse struct { + Result string `json:"result"` + Data []chapterEntry `json:"data"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` +} + +type chapterEntry struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes chapterAttributes `json:"attributes"` +} + +type chapterAttributes struct { + Volume *string `json:"volume"` + Chapter *string `json:"chapter"` + Title string `json:"title"` + TranslatedLanguage string `json:"translatedLanguage"` + ExternalURL *string `json:"externalUrl"` + Pages int `json:"pages"` + PublishAt string `json:"publishAt"` +} + +type mangaInfoResponse struct { + Result string `json:"result"` + Data mangaInfoData `json:"data"` +} + +type mangaInfoData struct { + ID string `json:"id"` + Attributes mangaInfoAttributes `json:"attributes"` +} + +type mangaInfoAttributes struct { + Title map[string]string `json:"title"` + ChapterNumbersResetOnNewVolume bool `json:"chapterNumbersResetOnNewVolume"` +} + +func GetScanInfo( + domain string, + workURL string, + language string, + log *logger.Logger, +) (*ScanInfo, error) { + mangaID, err := extractMangaID(workURL) + if err != nil { + return nil, err + } + + language = strings.TrimSpace(language) + if language == "" { + return nil, fmt.Errorf("MangaDex language is empty") + } + + mangaName, resetsOnNewVolume, err := getMangaInfo( + workURL, + log, + ) + if err != nil { + return nil, err + } + + // goweeb currently represents chapter numbers as a single int. + // If a manga restarts chapter numbering for every volume, range + // selection would become ambiguous. + if resetsOnNewVolume { + return nil, fmt.Errorf( + "MangaDex manga %q resets chapter numbers on new volumes, which is not supported yet", + mangaName, + ) + } + + baseURL, err := url.Parse(strings.TrimSuffix(domain, "/")) + if err != nil { + return nil, fmt.Errorf( + "invalid MangaDex domain: %w", + err, + ) + } + + feedRef, err := url.Parse( + fmt.Sprintf("/manga/%s/feed", mangaID), + ) + if err != nil { + return nil, fmt.Errorf( + "failed to build MangaDex chapter feed URL: %w", + err, + ) + } + + feedURL := baseURL.ResolveReference(feedRef) + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + // MangaDex can contain several translations of the exact same + // chapter in the same language, uploaded by different groups. + // + // goweeb currently expects one Entry per chapter number, so keep + // the most recently published version. + chaptersByNumber := make(map[int]ChapterInfo) + + offset := 0 + skippedNonInteger := 0 + + for { + requestURL := *feedURL + + query := requestURL.Query() + + query.Set("limit", strconv.Itoa(chapterFeedPageSize)) + query.Set("offset", strconv.Itoa(offset)) + + query.Add( + "translatedLanguage[]", + language, + ) + + query.Set( + "order[chapter]", + "asc", + ) + + // Only chapters that goweeb can actually download. + query.Set( + "includeEmptyPages", + "0", + ) + + query.Set( + "includeExternalUrl", + "0", + ) + + query.Set( + "includeFuturePublishAt", + "0", + ) + + requestURL.RawQuery = query.Encode() + + log.Debug( + "Fetching MangaDex chapter feed: %s\n", + requestURL.String(), + ) + + req, err := http.NewRequest( + http.MethodGet, + requestURL.String(), + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create MangaDex chapter feed request: %w", + err, + ) + } + + req.Header.Set( + "Accept", + "application/json", + ) + + req.Header.Set( + "User-Agent", + "goweeb", + ) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to fetch MangaDex chapter feed: %w", + err, + ) + } + + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + + return nil, fmt.Errorf( + "MangaDex chapter feed returned status: %d", + resp.StatusCode, + ) + } + + var response chapterFeedResponse + + err = json.NewDecoder(resp.Body).Decode(&response) + resp.Body.Close() + + if err != nil { + return nil, fmt.Errorf( + "failed to decode MangaDex chapter feed: %w", + err, + ) + } + + if response.Result != "ok" { + return nil, fmt.Errorf( + "MangaDex returned unexpected result: %s", + response.Result, + ) + } + + log.Debug( + "MangaDex chapter feed page: offset=%d results=%d total=%d\n", + response.Offset, + len(response.Data), + response.Total, + ) + + for _, chapter := range response.Data { + if chapter.ID == "" { + continue + } + + // These should already be excluded by the API filters, + // but keeping the checks here protects us against an + // unexpected API response. + if chapter.Attributes.Pages <= 0 { + continue + } + + if chapter.Attributes.ExternalURL != nil && + strings.TrimSpace(*chapter.Attributes.ExternalURL) != "" { + continue + } + + if chapter.Attributes.Chapter == nil { + log.Debug( + "Skipping MangaDex chapter %s with no chapter number\n", + chapter.ID, + ) + continue + } + + chapterNumberText := strings.TrimSpace( + *chapter.Attributes.Chapter, + ) + + if chapterNumberText == "" { + continue + } + + chapterNumber, err := strconv.Atoi( + chapterNumberText, + ) + if err != nil { + skippedNonInteger++ + + log.Debug( + "Skipping unsupported non-integer MangaDex chapter: %s\n", + chapterNumberText, + ) + + continue + } + + chapterRef, err := url.Parse( + fmt.Sprintf( + "/chapter/%s", + chapter.ID, + ), + ) + if err != nil { + log.Debug( + "Skipping invalid MangaDex chapter ID: %s\n", + chapter.ID, + ) + + continue + } + + chapterURL := baseURL.ResolveReference( + chapterRef, + ).String() + + label := fmt.Sprintf( + "Chapter %s", + chapterNumberText, + ) + + info := ChapterInfo{ + Number: chapterNumber, + Label: label, + URL: chapterURL, + ID: chapter.ID, + PublishedAt: chapter.Attributes.PublishAt, + } + + existing, exists := chaptersByNumber[chapterNumber] + + if !exists || isNewerChapter( + info, + existing, + ) { + chaptersByNumber[chapterNumber] = info + } + } + + nextOffset := response.Offset + len(response.Data) + + if nextOffset >= response.Total { + break + } + + if len(response.Data) == 0 { + return nil, fmt.Errorf( + "MangaDex chapter feed pagination stopped unexpectedly at offset %d of %d", + response.Offset, + response.Total, + ) + } + + offset = nextOffset + } + + chapters := make( + []ChapterInfo, + 0, + len(chaptersByNumber), + ) + + for _, chapter := range chaptersByNumber { + chapters = append( + chapters, + chapter, + ) + } + + sort.Slice( + chapters, + func(i, j int) bool { + return chapters[i].Number < + chapters[j].Number + }, + ) + + if len(chapters) == 0 { + return nil, fmt.Errorf( + "no downloadable MangaDex chapters found for language %s", + language, + ) + } + + log.Debug( + "Found %d unique MangaDex chapter(s) for language %s\n", + len(chapters), + language, + ) + + if skippedNonInteger > 0 { + log.Debug( + "Skipped %d non-integer MangaDex chapter(s)\n", + skippedNonInteger, + ) + } + + return &ScanInfo{ + MangaName: mangaName, + Chapters: chapters, + }, nil +} + +func getMangaInfo( + workURL string, + log *logger.Logger, +) (string, bool, error) { + log.Debug( + "Fetching MangaDex manga info: %s\n", + workURL, + ) + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + workURL, + nil, + ) + if err != nil { + return "", false, fmt.Errorf( + "failed to create MangaDex manga info request: %w", + err, + ) + } + + req.Header.Set( + "Accept", + "application/json", + ) + + req.Header.Set( + "User-Agent", + "goweeb", + ) + + resp, err := client.Do(req) + if err != nil { + return "", false, fmt.Errorf( + "failed to fetch MangaDex manga info: %w", + err, + ) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", false, fmt.Errorf( + "MangaDex manga info returned status: %d", + resp.StatusCode, + ) + } + + var response mangaInfoResponse + + if err := json.NewDecoder(resp.Body).Decode( + &response, + ); err != nil { + return "", false, fmt.Errorf( + "failed to decode MangaDex manga info: %w", + err, + ) + } + + if response.Result != "ok" { + return "", false, fmt.Errorf( + "MangaDex returned unexpected result: %s", + response.Result, + ) + } + + title := selectMangaTitle( + response.Data.Attributes.Title, + ) + + if title == "" { + title = response.Data.ID + } + + return title, + response.Data.Attributes.ChapterNumbersResetOnNewVolume, + nil +} + +func extractMangaID( + workURL string, +) (string, error) { + u, err := url.Parse(workURL) + if err != nil { + return "", fmt.Errorf( + "invalid MangaDex manga URL: %w", + err, + ) + } + + parts := strings.Split( + strings.Trim(u.Path, "/"), + "/", + ) + + if len(parts) < 2 { + return "", fmt.Errorf( + "unexpected MangaDex manga URL: %s", + workURL, + ) + } + + for i := 0; i < len(parts)-1; i++ { + if parts[i] == "manga" { + mangaID := strings.TrimSpace( + parts[i+1], + ) + + if mangaID != "" { + return mangaID, nil + } + } + } + + return "", fmt.Errorf( + "unable to extract MangaDex manga ID from URL: %s", + workURL, + ) +} + +func isNewerChapter( + candidate ChapterInfo, + current ChapterInfo, +) bool { + candidateTime, candidateErr := time.Parse( + time.RFC3339, + candidate.PublishedAt, + ) + + currentTime, currentErr := time.Parse( + time.RFC3339, + current.PublishedAt, + ) + + if candidateErr == nil && currentErr == nil { + if candidateTime.Equal(currentTime) { + return candidate.ID > current.ID + } + + return candidateTime.After(currentTime) + } + + if candidateErr == nil && currentErr != nil { + return true + } + + if candidateErr != nil && currentErr == nil { + return false + } + + // Deterministic fallback. + return candidate.ID > current.ID +} diff --git a/internal/source/mangadex/scraper/languages.go b/internal/source/mangadex/scraper/languages.go new file mode 100644 index 0000000..a180272 --- /dev/null +++ b/internal/source/mangadex/scraper/languages.go @@ -0,0 +1,129 @@ +package mangadexscraper + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" +) + +type mangaDetailsResponse struct { + Result string `json:"result"` + Data mangaDetailsData `json:"data"` +} + +type mangaDetailsData struct { + ID string `json:"id"` + Attributes mangaDetailsAttributes `json:"attributes"` +} + +type mangaDetailsAttributes struct { + AvailableTranslatedLanguages []string `json:"availableTranslatedLanguages"` +} + +func GetAvailableLanguages( + workURL string, + log *logger.Logger, +) ([]string, error) { + workURL = strings.TrimSpace(workURL) + + if workURL == "" { + return nil, fmt.Errorf("MangaDex manga URL is empty") + } + + log.Debug( + "Fetching MangaDex manga details: %s\n", + workURL, + ) + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + workURL, + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create MangaDex manga details request: %w", + err, + ) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "goweeb") + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to fetch MangaDex manga details: %w", + err, + ) + } + defer resp.Body.Close() + + log.Debug( + "MangaDex manga details response status: %d\n", + resp.StatusCode, + ) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "MangaDex manga details returned status: %d", + resp.StatusCode, + ) + } + + var response mangaDetailsResponse + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf( + "failed to decode MangaDex manga details: %w", + err, + ) + } + + if response.Result != "ok" { + return nil, fmt.Errorf( + "MangaDex returned unexpected result: %s", + response.Result, + ) + } + + languages := make([]string, 0) + seen := make(map[string]struct{}) + + for _, language := range response.Data.Attributes.AvailableTranslatedLanguages { + language = strings.TrimSpace(language) + + if language == "" { + continue + } + + if _, exists := seen[language]; exists { + continue + } + + seen[language] = struct{}{} + languages = append(languages, language) + } + + if len(languages) == 0 { + return nil, fmt.Errorf( + "no translated languages available for MangaDex manga %s", + response.Data.ID, + ) + } + + log.Debug( + "Found %d translated language(s)\n", + len(languages), + ) + + return languages, nil +} diff --git a/internal/source/mangadex/scraper/pages.go b/internal/source/mangadex/scraper/pages.go new file mode 100644 index 0000000..6e66973 --- /dev/null +++ b/internal/source/mangadex/scraper/pages.go @@ -0,0 +1,249 @@ +package mangadexscraper + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/EliasLd/goweeb/internal/logger" +) + +const mangaDexImageQuality = "data" + +type atHomeResponse struct { + Result string `json:"result"` + BaseURL string `json:"baseUrl"` + Chapter atHomeChapter `json:"chapter"` +} + +type atHomeChapter struct { + Hash string `json:"hash"` + Data []string `json:"data"` + DataSaver []string `json:"dataSaver"` +} + +func GetPageImageURLs( + domain string, + entryURL string, + log *logger.Logger, +) ([]string, error) { + chapterID, err := extractChapterID(entryURL) + if err != nil { + return nil, err + } + + baseAPIURL, err := url.Parse( + strings.TrimSuffix(domain, "/"), + ) + if err != nil { + return nil, fmt.Errorf( + "invalid MangaDex domain: %w", + err, + ) + } + + atHomeRef, err := url.Parse( + fmt.Sprintf( + "/at-home/server/%s", + url.PathEscape(chapterID), + ), + ) + if err != nil { + return nil, fmt.Errorf( + "failed to build MangaDex At-Home URL: %w", + err, + ) + } + + atHomeURL := baseAPIURL.ResolveReference( + atHomeRef, + ).String() + + log.Debug( + "Fetching MangaDex At-Home server: %s\n", + atHomeURL, + ) + + client := &http.Client{ + Timeout: 15 * time.Second, + } + + req, err := http.NewRequest( + http.MethodGet, + atHomeURL, + nil, + ) + if err != nil { + return nil, fmt.Errorf( + "failed to create MangaDex At-Home request: %w", + err, + ) + } + + req.Header.Set( + "Accept", + "application/json", + ) + + req.Header.Set( + "User-Agent", + "goweeb", + ) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf( + "failed to fetch MangaDex At-Home server: %w", + err, + ) + } + defer resp.Body.Close() + + log.Debug( + "MangaDex At-Home response status: %d\n", + resp.StatusCode, + ) + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf( + "MangaDex At-Home returned status: %d", + resp.StatusCode, + ) + } + + var response atHomeResponse + + if err := json.NewDecoder(resp.Body).Decode( + &response, + ); err != nil { + return nil, fmt.Errorf( + "failed to decode MangaDex At-Home response: %w", + err, + ) + } + + if response.Result != "ok" { + return nil, fmt.Errorf( + "MangaDex At-Home returned unexpected result: %s", + response.Result, + ) + } + + baseURL := strings.TrimSpace( + response.BaseURL, + ) + + chapterHash := strings.TrimSpace( + response.Chapter.Hash, + ) + + if baseURL == "" { + return nil, fmt.Errorf( + "MangaDex At-Home returned an empty base URL", + ) + } + + if chapterHash == "" { + return nil, fmt.Errorf( + "MangaDex At-Home returned an empty chapter hash", + ) + } + + files := response.Chapter.Data + + if len(files) == 0 { + return nil, fmt.Errorf( + "MangaDex At-Home returned no pages for chapter %s", + chapterID, + ) + } + + // MangaDex explicitly documents baseUrl as an opaque string. + // Do not parse it or make assumptions about its host/path. + baseURL = strings.TrimRight( + baseURL, + "/", + ) + + imageURLs := make( + []string, + 0, + len(files), + ) + + for _, filename := range files { + filename = strings.TrimSpace(filename) + + if filename == "" { + continue + } + + imageURL := fmt.Sprintf( + "%s/%s/%s/%s", + baseURL, + mangaDexImageQuality, + url.PathEscape(chapterHash), + url.PathEscape(filename), + ) + + imageURLs = append( + imageURLs, + imageURL, + ) + } + + if len(imageURLs) == 0 { + return nil, fmt.Errorf( + "no valid MangaDex page URLs found for chapter %s", + chapterID, + ) + } + + log.Debug( + "Found %d MangaDex page image(s)\n", + len(imageURLs), + ) + + return imageURLs, nil +} + +func extractChapterID( + entryURL string, +) (string, error) { + u, err := url.Parse( + strings.TrimSpace(entryURL), + ) + if err != nil { + return "", fmt.Errorf( + "invalid MangaDex chapter URL: %w", + err, + ) + } + + parts := strings.Split( + strings.Trim(u.Path, "/"), + "/", + ) + + for i := 0; i < len(parts)-1; i++ { + if parts[i] != "chapter" { + continue + } + + chapterID := strings.TrimSpace( + parts[i+1], + ) + + if chapterID != "" { + return chapterID, nil + } + } + + return "", fmt.Errorf( + "unable to extract MangaDex chapter ID from URL: %s", + entryURL, + ) +} diff --git a/internal/source/source.go b/internal/source/source.go index ce5f7d1..2a371d3 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/EliasLd/goweeb/internal/source/animesama" + "github.com/EliasLd/goweeb/internal/source/mangadex" "github.com/EliasLd/goweeb/internal/source/mangafreak" sourcetypes "github.com/EliasLd/goweeb/internal/source/types" "github.com/EliasLd/goweeb/internal/source/weebcentral" @@ -40,6 +41,12 @@ var providers = map[string]providerRegistration{ return weebcentral.New(customDomain) }, }, + "mangadex": { + Label: "MangaDex (Multilingual)", + New: func(customDomain string) sourcetypes.Provider { + return mangadex.New(customDomain) + }, + }, } func NewSource(name, customDomain string) (sourcetypes.Provider, error) { diff --git a/internal/source/types/types.go b/internal/source/types/types.go index 1fad96f..ed5c652 100644 --- a/internal/source/types/types.go +++ b/internal/source/types/types.go @@ -28,11 +28,17 @@ type Work struct { Kind ItemKind } +// Optional capability for providers that require specific HTTP headers +// when downloading page images. +type ImageRequestConfigurer interface { + ImageRequestHeaders() map[string]string +} + type Provider interface { Name() string Search(query string, log *logger.Logger) ([]SearchResult, error) - // Returns scan versions when the source exposes them (e.g. anime-sama). + // Returns scan versions when the source exposes them (e.g. mangadex, anime-sama...). // For sources without variants, return one implicit default item. ListScanPaths(workURL string, log *logger.Logger) ([]common.SelectableItem, error) diff --git a/internal/tui/model.go b/internal/tui/model.go index 443f7a5..3cde455 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -2,12 +2,13 @@ package tui import ( "bufio" + "github.com/EliasLd/goweeb/internal/app" + sourcetypes "github.com/EliasLd/goweeb/internal/source/types" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" "io" "os" "path/filepath" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" ) var asciiArt string = ` @@ -68,7 +69,10 @@ type Model struct { SelectedMangaURL string SelectedScanPath string - DiscoveredEntries int + DiscoveredEntries int + DiscoveredEntryList []sourcetypes.Entry + AvailableRanges string + SelectedRange app.RangeSelection } func getDefaultScanDir() string { diff --git a/internal/tui/update.go b/internal/tui/update.go index 6f49752..13f31c3 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -35,9 +35,9 @@ type scanPathResultMsg struct { err error } -type entriesCountResultMsg struct { - count int - err error +type entriesResultMsg struct { + entries []sourcetypes.Entry + err error } func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { @@ -109,10 +109,10 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { m.Logs = append( m.Logs, - "Scan version selected. Fetching chapter count...", + "Scan version selected. Fetching chapters...", ) - return m, fetchEntriesCount( + return m, fetchEntries( m.SelectedProvider, m.SelectedMangaURL, m.SelectedScanPath, @@ -131,18 +131,44 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { m.State = StateScanSelection return m, nil - case entriesCountResultMsg: + case entriesResultMsg: if msg.err != nil { - m.Logs = append(m.Logs, errorStyle.Render(fmt.Sprintf("[E] Failed to fetch chapter count: %v", msg.err))) + m.Logs = append( + m.Logs, + errorStyle.Render( + fmt.Sprintf( + "[E] Failed to fetch chapters: %v", + msg.err, + ), + ), + ) + return m, nil } - m.DiscoveredEntries = msg.count + if len(msg.entries) == 0 { + m.Logs = append( + m.Logs, + errorStyle.Render("[E] No chapters found"), + ) + + return m, nil + } + + m.DiscoveredEntries = len(msg.entries) + m.DiscoveredEntryList = msg.entries + m.AvailableRanges = app.FormatAvailableRanges( + msg.entries, + ) + + m.SelectedRange = app.RangeSelection{} + m.State = StateRangeSelection m.Cursor = 0 m.AllCheckbox.Checked = false m.RangeInput.SetValue("") m.RangeInput.Focus() + return m, nil case tea.KeyMsg: @@ -180,14 +206,82 @@ func Update(msg tea.Msg, m Model) (Model, tea.Cmd) { m = updateRangeFocus(m) return m, nil case 2: - if m.AllCheckbox.Checked || strings.TrimSpace(m.RangeInput.Value()) != "" { - m.IsDownloading = true - m.State = StateDownloading - m.Logs = append(m.Logs, "Starting download...") - return m, startDownload(m) + var selection app.RangeSelection + + if m.AllCheckbox.Checked { + selection.All = true + } else { + input := strings.TrimSpace( + m.RangeInput.Value(), + ) + + if input == "" { + m.Logs = append( + m.Logs, + errorStyle.Render( + "[E] Please enter a range or enable 'Download all chapters'.", + ), + ) + + return m, nil + } + + parsed, err := app.ParseRangeExpression( + input, + ) + if err != nil { + m.Logs = append( + m.Logs, + errorStyle.Render( + fmt.Sprintf( + "[E] Invalid range: %v", + err, + ), + ), + ) + + return m, nil + } + + selection = parsed } - m.Logs = append(m.Logs, errorStyle.Render("[E] Please enter a valid range or enable 'Download all chapters'.")) - return m, nil + + matched := app.FilterEntriesBySelection( + m.DiscoveredEntryList, + selection, + ) + + if len(matched) == 0 { + m.Logs = append( + m.Logs, + errorStyle.Render( + "[E] No available chapters match this selection.", + ), + ) + + return m, nil + } + + m.SelectedRange = selection + + m.Logs = append( + m.Logs, + fmt.Sprintf( + "Selected %d chapter(s): %s", + len(matched), + app.FormatAvailableRanges(matched), + ), + ) + + m.IsDownloading = true + m.State = StateDownloading + + m.Logs = append( + m.Logs, + "Starting download...", + ) + + return m, startDownload(m) } } @@ -356,7 +450,11 @@ func handleProviderSelectionUpdate( if oldProvider != newProvider || oldDomain != newDomain { m.SelectedMangaURL = "" m.SelectedScanPath = "" + m.DiscoveredEntries = 0 + m.DiscoveredEntryList = nil + m.AvailableRanges = "" + m.SelectedRange = app.RangeSelection{} } m.State = StateForm @@ -418,8 +516,8 @@ func handleSelectionUpdate(msg tea.Msg, m Model) (Model, tea.Cmd) { } else if m.State == StateScanSelection { m.SelectedScanPath = m.SelectionModel.Selected m.State = StateForm - m.Logs = append(m.Logs, "Scan version selected. Fetching chapter count...") - return m, fetchEntriesCount( + m.Logs = append(m.Logs, "Scan version selected. Fetching chapters...") + return m, fetchEntries( m.SelectedProvider, m.SelectedMangaURL, m.SelectedScanPath, @@ -524,21 +622,24 @@ func fetchScanPaths( } } -func fetchEntriesCount( +func fetchEntries( providerName string, mangaURL string, scanPath string, customDomain string, ) tea.Cmd { return func() tea.Msg { - log := logger.New(io.Discard, logger.LevelInfo) + log := logger.New( + io.Discard, + logger.LevelInfo, + ) provider, err := source.New( providerName, strings.TrimSpace(customDomain), ) if err != nil { - return entriesCountResultMsg{ + return entriesResultMsg{ err: err, } } @@ -549,13 +650,13 @@ func fetchEntriesCount( log, ) if err != nil { - return entriesCountResultMsg{ + return entriesResultMsg{ err: err, } } - return entriesCountResultMsg{ - count: len(entries), + return entriesResultMsg{ + entries: entries, } } } @@ -574,7 +675,7 @@ func startDownload(m Model) tea.Cmd { opts := app.Options{ Slug: m.MangaInput.Value(), Source: m.SelectedProvider, - All: m.AllCheckbox.Checked, + Selection: m.SelectedRange, ScanDir: m.ScanDirInput.Value(), Cleanup: !m.KeepCheckbox.Checked, CustomDomain: strings.TrimSpace(m.DomainInput.Value()), @@ -583,27 +684,25 @@ func startDownload(m Model) tea.Cmd { EbookFriendly: m.EbookCheckbox.Checked, } - if !opts.All { - r := strings.TrimSpace(m.RangeInput.Value()) - chapterRange, rangeMode, err := app.ParseRangeString(r) - if err != nil { - // Fallback: keep empty range to let backend guard logs report invalid input. - // This should rarely happen because UI validates before starting. - opts.RangeMode = app.RangeNone - } else { - opts.Range = chapterRange - opts.RangeMode = rangeMode - } - } - pr, pw := io.Pipe() + go func() { - log := logger.New(pw, logger.LevelInfo) - app.RunWithWorkflow(opts, log) + log := logger.New( + pw, + logger.LevelInfo, + ) + + app.RunWithWorkflow( + opts, + log, + ) + _ = pw.Close() }() - return setupLogPipeMsg{reader: pr} + return setupLogPipeMsg{ + reader: pr, + } } } diff --git a/internal/tui/view.go b/internal/tui/view.go index 4882e3a..fe5301f 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -139,52 +139,131 @@ func viewForm(m Model) string { func viewRangeSelection(m Model) string { var b strings.Builder - title := fmt.Sprintf("Found %d chapters", m.DiscoveredEntries) + title := fmt.Sprintf( + "Found %d chapter(s)", + m.DiscoveredEntries, + ) + b.WriteString(titleStyle.Render(title)) b.WriteString("\n\n") + if strings.TrimSpace(m.AvailableRanges) != "" { + b.WriteString(labelStyle.Render("Available chapters")) + b.WriteString("\n") + + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Render(m.AvailableRanges), + ) + + b.WriteString("\n\n") + } + b.WriteString(labelStyle.Render("Chapter range")) b.WriteString("\n\n") if m.AllCheckbox.Checked { - b.WriteString(lipgloss.NewStyle().Faint(true).Render(m.RangeInput.View())) + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Render(m.RangeInput.View()), + ) + b.WriteString("\n") - b.WriteString(lipgloss.NewStyle().Faint(true).Italic(true).Render("Disabled because 'Download all chapters' is enabled.")) + + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Italic(true). + Render( + "Disabled because 'Download all chapters' is enabled.", + ), + ) } else { if m.Cursor == 0 { b.WriteString(m.RangeInput.View()) } else { - b.WriteString(lipgloss.NewStyle().Faint(true).Render(m.RangeInput.View())) + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Render(m.RangeInput.View()), + ) } } + b.WriteString("\n\n") - b.WriteString(lipgloss.NewStyle().Faint(true).Render("Accepted formats: 1-10, 10, -10, 10-")) + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Render( + "Accepted formats: 10, 1-10, 10-, -10, 1-10,20-30", + ), + ) + + b.WriteString("\n") + + b.WriteString( + lipgloss.NewStyle(). + Faint(true). + Italic(true). + Render( + "Unavailable chapters inside a range are skipped automatically.", + ), + ) + b.WriteString("\n\n") - b.WriteString(m.AllCheckbox.View(m.Cursor == 1)) + b.WriteString( + m.AllCheckbox.View(m.Cursor == 1), + ) + b.WriteString("\n\n") btn := "[ Download ]" + if m.Cursor == 2 { - btn = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("226")).Render(btn) + btn = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("226")). + Render(btn) } else { - btn = lipgloss.NewStyle().Faint(true).Render(btn) + btn = lipgloss.NewStyle(). + Faint(true). + Render(btn) } + b.WriteString(btn) b.WriteString("\n\n") - footerStyle := lipgloss.NewStyle().Faint(true) - b.WriteString(footerStyle.Render("↑/↓ navigate • Space/Enter toggle • Enter on Download • Ctrl+C/Esc quit")) + footerStyle := lipgloss.NewStyle(). + Faint(true) + + b.WriteString( + footerStyle.Render( + "↑/↓ navigate • Space/Enter toggle • Enter on Download • Ctrl+C/Esc quit", + ), + ) boxWidth := lipgloss.Width(b.String()) boxHeight := lipgloss.Height(b.String()) - horizontalMargin := max(0, (m.Width-boxWidth)/2) - verticalMargin := max(0, (m.Height-boxHeight)/2) + + horizontalMargin := max( + 0, + (m.Width-boxWidth)/2, + ) + + verticalMargin := max( + 0, + (m.Height-boxHeight)/2, + ) boxStyle := lipgloss.NewStyle(). MarginTop(verticalMargin). MarginLeft(horizontalMargin) - return boxStyle.Render(b.String()) + return boxStyle.Render( + b.String(), + ) }