diff --git a/README.md b/README.md index 3f0e49a..128f4da 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Curd -A cli application to stream anime with [Anilist](https://anilist.co/) integration and Discord RPC written in golang. +A cli application to stream anime with [Anilist](https://anilist.co/) and [MyAnimeList](https://myanimelist.net/) integration and Discord RPC written in golang. Works on Windows and Linux ## Join the discord server @@ -21,7 +21,8 @@ https://github.com/user-attachments/assets/cbf799bc-9fdd-4402-ab61-b4e31f1e264d ## Features - Stream anime online -- Update anime in Anilist after completion +- **Dual Provider Support**: Choose between [Anilist](https://anilist.co/) or [MyAnimeList (MAL)](https://myanimelist.net/) to track your anime +- Update anime progress automatically on your chosen provider after completion - Skip anime Intro and Outro - Skip Filler and Recap episodes - Discord RPC about the anime @@ -337,11 +338,193 @@ You can quit it anytime and the resume time would be saved in the history file more settings can be found at config file. config file is located at ```~/.config/curd/curd.conf``` +## Using MyAnimeList (MAL) with Curd + +Curd supports both AniList and MyAnimeList as anime tracking providers. Here's a comprehensive step-by-step guide to set up and use MAL with Curd. + +### Quick Overview + +**What you'll need:** +- A MyAnimeList account +- MAL API Client credentials (Client ID and Client Secret) +- 5-10 minutes for setup + +**Setup Steps:** +1. Create a MAL API Client → Get Client ID and Secret +2. Configure Curd → Add credentials to config file +3. Authenticate → Login via OAuth +4. Start watching → Enjoy automatic progress tracking! + +--- + +### Step 1: Create a MAL API Client + +Before you can use Curd with MyAnimeList, you need to create a MAL API client to get your Client ID and Client Secret. + +1. **Sign in to MyAnimeList** + - Go to [MyAnimeList](https://myanimelist.net/) and log in to your account + - If you don't have an account, create one first + +2. **Access API Settings** + - Navigate to [MAL API Client Registration](https://myanimelist.net/apiconfig) + - Or go to: Profile → Account Settings → API + +3. **Create New Client** + - Click on "Create ID" or "Add New Client" + - Fill in the required information: + - **App Name**: `Curd` (or any name you prefer) + - **App Type**: Select `web` + - **App Description**: `Curd is a anime streaming CLI application that I use to watch anime and track my progress on MyAnimeList.` + - **App Redirect URL**: `http://localhost:8080/callback` + - **Homepage URL**: `https://github.com/Wraient/curd` (optional) + - **Commercial/Non-Commercial**: Select `non-commercial` + +4. **Save Your Credentials** + - After creating, you'll receive: + - **Client ID**: A string of characters (save this) + - **Client Secret**: Another string of characters (save this securely) + - **Important**: Keep these credentials private and secure! + +### Step 2: Configure Curd to Use MAL + +1. **Edit the Curd Configuration File** + ```bash + curd -e + ``` + + Or manually open the config file: + - **Linux/macOS**: `~/.config/curd/curd.conf` + - **Windows**: `C:\Users\USERNAME\AppData\Roaming\Curd\curd.conf` + +2. **Update the Following Settings** + ```conf + AnimeProvider = mal + MALClientID = your_client_id_here + MALClientSecret = your_client_secret_here + ``` + + Replace `your_client_id_here` and `your_client_secret_here` with the credentials you saved from Step 1. + +3. **Save the Configuration File** + +### Step 3: Authenticate with MAL + +1. **Run Curd for the First Time** + ```bash + curd + ``` + +2. **OAuth Authentication Flow** + - Curd will automatically open your default web browser + - You'll be redirected to MyAnimeList's authorization page + - Click "Allow" or "Authorize" to grant Curd access to your MAL account + +3. **Complete Authentication** + - After authorization, you'll be redirected to a callback page + - The authentication token will be saved automatically + - You can close the browser window + +4. **Token Storage** + - Your MAL token is securely stored at: + - **Linux/macOS**: `~/.local/share/curd/mal_token.json` + - **Windows**: `C:\.local\share\curd\mal_token.json` + +### Step 4: Start Using Curd with MAL + +Once authenticated, Curd will: +- Display your MAL anime lists (Watching, Completed, On Hold, Dropped, Plan to Watch) +- Automatically update your MAL progress as you watch episodes +- Sync your scores and status changes +- Allow you to add new anime to your MAL lists + +**Example Usage:** +```bash +# Start watching from your Currently Watching list +curd + +# Continue your last watched anime +curd -c + +# Add a new anime to your MAL list +curd -new + +# Watch with rofi interface +curd -rofi +``` + +### Step 5: Managing Your MAL Integration + +#### Switching Back to AniList +If you want to switch back to AniList: +1. Edit the config: `curd -e` +2. Change: `AnimeProvider = anilist` +3. Save and restart Curd + +#### Re-authenticating MAL +If you need to re-authenticate (token expired, changed accounts, etc.): +1. Delete the token file: + ```bash + # Linux/macOS + rm ~/.local/share/curd/mal_token.json + + # Windows + del C:\.local\share\curd\mal_token.json + ``` +2. Run `curd` again to start the authentication process + +#### Viewing Your Token +Your MAL authentication token is stored in JSON format at the token file location. It includes: +- Access token +- Refresh token +- Token expiration time + +### Troubleshooting + +**Problem**: "Failed to get MAL user info" or authentication errors + +**Solutions**: +1. Verify your Client ID and Client Secret in the config file +2. Ensure the redirect URI in your MAL API settings matches: `http://localhost:8080/callback` +3. Delete the token file and re-authenticate +4. Check that port 8080 is not blocked by a firewall + +**Problem**: Anime not updating on MAL + +**Solutions**: +1. Check your internet connection +2. Verify the token hasn't expired (re-authenticate if needed) +3. Check the debug log at: `~/.local/share/curd/debug.log` (Linux/macOS) or `C:\.local\share\curd\debug.log` (Windows) + +**Problem**: Can't find anime when searching + +**Solutions**: +1. Try searching with the English title instead of Romaji +2. Use alternative titles or abbreviations +3. The anime might not be in MAL's database yet + +### MAL vs AniList Comparison + +| Feature | AniList | MyAnimeList | +|---------|---------|-------------| +| Anime Coverage | Extensive | Extensive | +| Scoring System | 0-100 | 0-10 | +| Image Previews in Rofi | Yes | Yes | +| Progress Tracking | Yes | Yes | +| Status Categories | 6 categories | 5 categories | +| OAuth Setup | Simple | Requires API Client | + +### Additional Notes + +- MAL uses a 0-10 scoring system, while AniList uses 0-100 +- When switching providers, your local watch history remains intact +- Both providers work with all Curd features (Discord RPC, skip features, etc.) +- You can maintain lists on both platforms by switching the provider setting + | **Option** | **Type** | **Valid Values** | **Description** | |---------------------------|------------|-------------------------------------------|---------------------------------------------------------------------------------------------------| | `DiscordPresence` | Boolean | `true`, `false` | Enables or disables Discord Rich Presence integration. | | `AnimeNameLanguage` | Enum | `english`, `romaji` | Sets the preferred language for anime names. | -| `MpvArgs` | List | all mpv args eg ["--fullscreen=yes", "--mute=yes"] | Add args to mpv player | +| `MpvArgs` | List | all mpv args eg ["--fullscreen=yes", "--mute=yes"] | Add args to mpv player | | `AddMissingOptions` | Boolean | `true`, `false` | Automatically adds missing configuration options with default values to the config file. | | `AlternateScreen` | Boolean | `true`, `false` | Toggles the use of an alternate screen buffer for cleaner UI. | | `RofiSelection` | Boolean | `true`, `false` | Enables or disables anime selection via Rofi. | @@ -358,6 +541,9 @@ config file is located at ```~/.config/curd/curd.conf``` | `Player` | String | `mpv` (redundant rn) | Specifies the media player used for streaming or playing anime. | | `SaveMpvSpeed` | Boolean | `true`, `false` | Retains the playback speed set in MPV for next episode. | | `SkipFiller` | Boolean | `true`, `false` | Skips filler episodes when supported. | +| `AnimeProvider` | Enum | `anilist`, `mal` | Specifies which anime tracking service to use (AniList or MyAnimeList). | +| `MALClientID` | String | Your MAL Client ID | Client ID from MyAnimeList API (required when using MAL as provider). | +| `MALClientSecret` | String | Your MAL Client Secret | Client Secret from MyAnimeList API (required when using MAL as provider). | ## Todo (fix) - Use Powershell for windows token input instead of notepad or cmd diff --git a/cmd/curd/main.go b/cmd/curd/main.go index a286982..af12a8e 100644 --- a/cmd/curd/main.go +++ b/cmd/curd/main.go @@ -148,13 +148,18 @@ func main() { userCurdConfig.SubOrDub = "dub" } - // Get the token from the token file - user.Token, err = internal.GetTokenFromFile(filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "anilist_token.json")) + // Get the token from the token file based on provider + user.Token, err = internal.GetProviderToken(&userCurdConfig) if err != nil { - internal.Log("Error reading token") + internal.Log("Error reading token: " + err.Error()) } if user.Token == "" { internal.ChangeToken(&userCurdConfig, &user) + // Get token again after authentication + user.Token, err = internal.GetProviderToken(&userCurdConfig) + if err != nil { + internal.ExitCurd(fmt.Errorf("failed to get token after authentication: %w", err)) + } } if userCurdConfig.RofiSelection { @@ -183,49 +188,115 @@ func main() { // internal.ExitCurd(fmt.Errorf("Added new anime!")) } - internal.SetupCurd(&userCurdConfig, &anime, &user, &databaseAnimes) - - temp_anime, err := internal.FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anime.AnilistId)) - if err != nil { - internal.Log("Error finding anime by Anilist ID: " + err.Error()) - } + // Outer loop to allow returning to main menu + // Flag to skip SetupCurd when anime was reselected via back navigation + skipSetup := false - if anime.TotalEpisodes == temp_anime.Progress { - internal.Log(temp_anime.Progress) - internal.Log(anime.TotalEpisodes) - internal.Log(user.AnimeList) - internal.Log("Rewatching anime: " + internal.GetAnimeName(anime)) - anime.Rewatching = true - } + for { + // Only reset and call SetupCurd if we're not coming from a back navigation + if !skipSetup { + // Reset anime struct for new selection + anime = internal.Anime{} + + // Setup and select anime + func() { + defer func() { + if r := recover(); r != nil { + if r == "BACK_TO_MAIN_MENU" { + // Don't do anything, just return from this function + // The outer loop will restart + return + } + // Re-panic if it's not our special signal + panic(r) + } + }() - anime.Ep.Player.Speed = 1.0 + internal.SetupCurd(&userCurdConfig, &anime, &user, &databaseAnimes) + }() - // Get filler list concurrently - go func() { - // Get MAL ID first if not already set - if anime.MalId == 0 { - malID, err := internal.GetAnimeMalID(anime.AnilistId) - if err != nil { - internal.Log("Error getting MAL ID: " + err.Error()) - return + // Check if panic occurred (anime.AnilistId will be 0) + if anime.AnilistId == 0 { + // Back to main menu was triggered, reload anime list and restart + internal.CurdOut("Returning to main menu...") + internal.ClearScreen() + user.AnimeList, err = internal.GetProviderAnimeList(&userCurdConfig, user.Token, user.Id) + if err != nil { + internal.Log(fmt.Sprintf("Failed to reload anime list: %v", err)) + } + continue } - anime.MalId = malID } - fillerList, err := internal.FetchFillerEpisodes(anime.MalId) + // Reset the skip flag for next iteration + skipSetup = false + + temp_anime, err := internal.FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anime.AnilistId)) if err != nil { - internal.Log("Error getting filler list: " + err.Error()) - } else { - anime.FillerEpisodes = fillerList - internal.Log("Filler list fetched successfully") - // fmt.Println("Filler episodes: ", anime.FillerEpisodes) + internal.Log("Error finding anime by Anilist ID: " + err.Error()) } - }() - // Main loop (loop to keep starting new episodes) - for { + if anime.TotalEpisodes == temp_anime.Progress { + internal.Log(temp_anime.Progress) + internal.Log(anime.TotalEpisodes) + internal.Log(user.AnimeList) + internal.Log("Rewatching anime: " + internal.GetAnimeName(anime)) + anime.Rewatching = true + } + + anime.Ep.Player.Speed = 1.0 + + // Get filler list concurrently + go func() { + // Get MAL ID first if not already set + if anime.MalId == 0 { + malID, err := internal.GetAnimeMalID(anime.AnilistId) + if err != nil { + internal.Log("Error getting MAL ID: " + err.Error()) + return + } + anime.MalId = malID + } + + fillerList, err := internal.FetchFillerEpisodes(anime.MalId) + if err != nil { + internal.Log("Error getting filler list: " + err.Error()) + } else { + anime.FillerEpisodes = fillerList + internal.Log("Filler list fetched successfully") + // fmt.Println("Filler episodes: ", anime.FillerEpisodes) + } + }() - internal.Log(anime) + // Main loop (loop to keep starting new episodes) + // Flag to track if anime was reselected via back navigation + var animeReselected bool + + // Wrap episode playback to catch BACK_TO_ANIME_SELECTION panic + func() { + defer func() { + if r := recover(); r != nil { + if r == "BACK_TO_ANIME_SELECTION" { + // Reset anime struct for new selection + anime = internal.Anime{} + + // Restart anime selection by calling SetupCurd again + internal.ClearScreen() + internal.SetupCurd(&userCurdConfig, &anime, &user, &databaseAnimes) + + // Set flag to indicate we reselected an anime + animeReselected = true + return + } + // Re-panic if it's not our signal + panic(r) + } + }() + + backToMenuRequested := false + episodeLoop: + for { + internal.Log(anime) // Create a channel to signal when to exit the skip loop var wg sync.WaitGroup @@ -276,7 +347,7 @@ func main() { // If not filler/recap (or skip is disabled), break and continue with playback if !((anime.Ep.IsFiller && userCurdConfig.SkipFiller) || (anime.Ep.IsRecap && userCurdConfig.SkipRecap)) { if anime.Ep.LastWasSkipped { - go internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1) + go internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number-1) } break } @@ -418,8 +489,23 @@ func main() { } }() + // Channel to signal back to main menu request + backToMenuCh := make(chan bool, 1) + // Thread for continuous next episode prompt in CLI mode (throughout episode duration) go func() { + defer func() { + if r := recover(); r != nil { + if r == "BACK_TO_ANIME_SELECTION" { + // Signal to go back to anime selection + backToMenuCh <- true + return + } + // Re-panic if it's not our special signal + panic(r) + } + }() + if userCurdConfig.NextEpisodePrompt && !userCurdConfig.RofiSelection { internal.NextEpisodePromptContinuous(&userCurdConfig, databaseFile, user.Token) // If the function returns, it means user made a decision @@ -444,6 +530,20 @@ func main() { select { case <-skipLoopDone: return + case <-backToMenuCh: + // User wants to go back to main menu + internal.Log("Received back to main menu signal") + backToMenuRequested = true + // Close the skip loop + select { + case isClosed := <-skipLoopClosed: + if !isClosed { + close(skipLoopDone) + skipLoopClosed <- true + } + default: + } + return default: time.Sleep(1 * time.Second) @@ -486,11 +586,11 @@ func main() { internal.Log("Error updating local database on quit: " + err.Error()) } - // Update Anilist progress if not rewatching + // Update provider progress if not rewatching if !anime.Rewatching { - err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) + err = internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { - internal.Log("Error updating Anilist progress on quit: " + err.Error()) + internal.Log("Error updating progress on quit: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } @@ -614,11 +714,11 @@ func main() { internal.Log("Error updating local database on quit: " + err.Error()) } - // Update Anilist progress if not rewatching + // Update provider progress if not rewatching if !anime.Rewatching { - err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) + err = internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { - internal.Log("Error updating Anilist progress on quit: " + err.Error()) + internal.Log("Error updating progress on quit: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } @@ -634,11 +734,11 @@ func main() { internal.Log("Error updating local database on completion: " + err.Error()) } - // Update Anilist progress if not rewatching + // Update provider progress if not rewatching if !anime.Rewatching { - err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) + err = internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { - internal.Log("Error updating Anilist progress on completion: " + err.Error()) + internal.Log("Error updating progress on completion: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } @@ -713,6 +813,12 @@ func main() { // Wait for all goroutines to finish before starting the next iteration wg.Wait() + // Check if user wants to go back to anime selection + if backToMenuRequested { + internal.Log("Breaking episode loop to return to anime selection") + break episodeLoop // Break out of episode loop and restart selection + } + // Reset the WaitGroup for the next loop wg = sync.WaitGroup{} @@ -727,9 +833,9 @@ func main() { if anime.TotalEpisodes > 0 && anime.Ep.Number-1 != anime.TotalEpisodes { go func() { // Update progress for regular episodes - err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1) + err = internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number-1) if err != nil { - internal.Log("Error updating Anilist progress: " + err.Error()) + internal.Log("Error updating progress: " + err.Error()) } }() } else { @@ -738,9 +844,9 @@ func main() { // Exit MPV internal.ExitMPV(anime.Ep.Player.SocketPath) - err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1) + err = internal.UpdateProviderAnimeProgress(&userCurdConfig, user.Token, anime.AnilistId, anime.Ep.Number-1) if err != nil { - internal.Log("Error updating Anilist progress: " + err.Error()) + internal.Log("Error updating progress: " + err.Error()) } } @@ -750,13 +856,13 @@ func main() { if anime.Ep.Number-1 == anime.TotalEpisodes && userCurdConfig.ScoreOnCompletion && anime.TotalEpisodes > 0 { // Get updated anime data to check if it's still airing - updatedAnime, err := internal.GetAnimeDataByID(anime.AnilistId, user.Token) + updatedAnime, err := internal.GetProviderAnimeDetails(&userCurdConfig, user.Token, anime.AnilistId) if err != nil { internal.Log("Error getting updated anime data: " + err.Error()) } else if !updatedAnime.IsAiring { anime.Ep.Number = anime.Ep.Number - 1 internal.CurdOut("Completed anime.") - err = internal.RateAnime(user.Token, anime.AnilistId) + err = internal.RateProviderAnime(&userCurdConfig, user.Token, anime.AnilistId, 0) if err != nil { internal.Log("Error rating anime: " + err.Error()) internal.CurdOut("Error rating anime: " + err.Error()) @@ -818,5 +924,32 @@ func main() { return } - } + } // end episode loop + + // If back to anime selection was requested, trigger the panic signal + if backToMenuRequested { + internal.CurdOut("Going back to anime selection...") + internal.ClearScreen() + // Reload anime list + user.AnimeList, err = internal.GetProviderAnimeList(&userCurdConfig, user.Token, user.Id) + if err != nil { + internal.Log(fmt.Sprintf("Failed to reload anime list: %v", err)) + } + // Trigger panic to go back to anime selection + panic("BACK_TO_ANIME_SELECTION") + } + + }() // end episode playback wrapper function with defer/recover + + // If anime was reselected via back button, continue the outer loop to start playback + if animeReselected { + internal.Log("Anime reselected, continuing outer loop for playback") + skipSetup = true // Skip SetupCurd on next iteration + continue + } + + // If we reach here normally, the series ended or user quit + // Break the outer loop + break + } // end outer loop (restart selection) } diff --git a/internal/config.go b/internal/config.go index c7e2361..1913ca0 100755 --- a/internal/config.go +++ b/internal/config.go @@ -60,6 +60,9 @@ type CurdConfig struct { AlternateScreen bool `config:"AlternateScreen"` DiscordPresence bool `config:"DiscordPresence"` DiscordClientId string `config:"DiscordClientId"` + AnimeProvider string `config:"AnimeProvider"` + MALClientID string `config:"MALClientID"` + MALClientSecret string `config:"MALClientSecret"` } // Default configuration values as a map @@ -86,6 +89,9 @@ func defaultConfigMap() map[string]string { "AlternateScreen": "true", "DiscordPresence": "true", "DiscordClientId": "1287457464148820089", + "AnimeProvider": "anilist", + "MALClientID": "", + "MALClientSecret": "", } } @@ -427,12 +433,22 @@ func GetTokenFromFile(tokenPath string) (string, error) { return "", fmt.Errorf("failed to read token from file: %w", err) } - // Try to parse as JSON first (new format) - var token AnilistToken - if err := json.Unmarshal(data, &token); err == nil { - // It's JSON format, check if token is valid - if isTokenValid(&token) { - return token.AccessToken, nil + // Try to parse as AnilistToken JSON format + var anilistToken AnilistToken + if err := json.Unmarshal(data, &anilistToken); err == nil && anilistToken.AccessToken != "" { + // It's AniList token format, check if token is valid + if isTokenValid(&anilistToken) { + return anilistToken.AccessToken, nil + } + return "", fmt.Errorf("token has expired") + } + + // Try to parse as MALTokenResponse JSON format + var malToken MALTokenResponse + if err := json.Unmarshal(data, &malToken); err == nil && malToken.AccessToken != "" { + // It's MAL token format, check if token is valid + if time.Now().Before(malToken.ExpiresAt) { + return malToken.AccessToken, nil } return "", fmt.Errorf("token has expired") } @@ -446,38 +462,235 @@ func GetTokenFromFile(tokenPath string) (string, error) { return plainToken, nil } -func ChangeToken(config *CurdConfig, user *User) { - var err error - tokenPath := filepath.Join(os.ExpandEnv(config.StoragePath), "anilist_token.json") +// authenticateMALWithBrowser performs MAL OAuth authentication using browser +func authenticateMALWithBrowser(config *CurdConfig, tokenPath string) (string, error) { + if config.MALClientID == "" { + return "", fmt.Errorf("MAL Client ID not configured. Please set MALClientID in your config file") + } + if config.MALClientSecret == "" { + return "", fmt.Errorf("MAL Client Secret not configured. Please set MALClientSecret in your config file") + } - // Try browser-based OAuth first - fmt.Println("Starting browser-based authentication...") - user.Token, err = authenticateWithBrowser(tokenPath) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + // Generate PKCE values + codeVerifier, err := GenerateCodeVerifier() if err != nil { - Log("Browser authentication failed: " + err.Error()) - fmt.Printf("Browser authentication failed: %v\n", err) - fmt.Println("Falling back to manual token entry...") + return "", fmt.Errorf("failed to generate code verifier: %w", err) + } + + codeChallenge := GenerateCodeChallenge(codeVerifier) + state, err := GenerateState() + if err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + + // Start local server to handle OAuth callback + callbackCh := make(chan *MALTokenResponse, 1) + errCh := make(chan error, 1) + mux := http.NewServeMux() + srv := &http.Server{ + Addr: ":8080", + Handler: mux, + } + + // Handle OAuth callback + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + code := r.URL.Query().Get("code") + returnedState := r.URL.Query().Get("state") + errorParam := r.URL.Query().Get("error") + + w.Header().Set("Content-Type", "text/html") + + if errorParam != "" { + w.WriteHeader(http.StatusBadRequest) + html := fmt.Sprintf(` + +
+You can close this window and try again.
+ +`, errorParam) + fmt.Fprint(w, html) + errCh <- fmt.Errorf("oauth error: %s", errorParam) + return + } + + if returnedState != state { + w.WriteHeader(http.StatusBadRequest) + html := ` + + +You can close this window and try again.
+ +` + fmt.Fprint(w, html) + errCh <- fmt.Errorf("state mismatch") + return + } + + if code == "" { + w.WriteHeader(http.StatusBadRequest) + html := ` + + +You can close this window and try again.
+ +` + fmt.Fprint(w, html) + errCh <- fmt.Errorf("no authorization code received") + return + } - // Simple CLI fallback - fmt.Println("Please visit: https://anilist.co/api/v2/oauth/authorize?client_id=20686&response_type=token&redirect_uri=http://localhost:8000/oauth/callback") - fmt.Print("Copy and paste your access token here: ") - fmt.Scanln(&user.Token) + // Exchange code for token + go func() { + tokenResp, err := ExchangeCodeForToken(config.MALClientID, config.MALClientSecret, code, "http://localhost:8080/callback", codeVerifier) + if err != nil { + errCh <- fmt.Errorf("failed to exchange code for token: %w", err) + return + } + callbackCh <- tokenResp + }() + + // Show success page + html := ` + + +You can close this window and return to the terminal.
+ +` + fmt.Fprint(w, html) + }) - if user.Token == "" { - ExitCurd(fmt.Errorf("no token provided")) + // Start server + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + errCh <- fmt.Errorf("failed to start server: %w", err) } + }() + defer srv.Shutdown(ctx) + + time.Sleep(100 * time.Millisecond) + + // Build and open auth URL + authURL := GetMALAuthorizationURL(config.MALClientID, "http://localhost:8080/callback", state, codeChallenge) + + fmt.Println("Opening browser for MAL authentication...") + fmt.Printf("If the browser doesn't open automatically, visit: %s\n", authURL) + + if err := browser.OpenURL(authURL); err != nil { + fmt.Printf("Failed to open browser automatically: %v\n", err) + fmt.Println("Please copy and paste the URL above into your browser") + } + + // Wait for token + var tokenData *MALTokenResponse + select { + case tokenData = <-callbackCh: + case err := <-errCh: + return "", fmt.Errorf("authentication failed: %w", err) + case <-ctx.Done(): + return "", fmt.Errorf("authentication timeout after 5 minutes") + } + + data, err := json.Marshal(tokenData) + if err != nil { + return "", fmt.Errorf("failed to marshal token: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(tokenPath), 0755); err != nil { + return "", fmt.Errorf("failed to create directory: %w", err) + } + + if err := os.WriteFile(tokenPath, data, 0600); err != nil { + return "", fmt.Errorf("failed to save token: %w", err) + } + + fmt.Println("MAL authentication successful!") + return tokenData.AccessToken, nil +} + +func ChangeToken(config *CurdConfig, user *User) { + var err error + + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + tokenPath := filepath.Join(os.ExpandEnv(config.StoragePath), "mal_token.json") + + fmt.Println("Starting MAL browser-based authentication...") + user.Token, err = authenticateMALWithBrowser(config, tokenPath) - // Save the manually entered token as JSON format - token := &AnilistToken{ - AccessToken: user.Token, - TokenType: "Bearer", - ExpiresIn: 31536000, // AniList tokens are valid for 1 year - ExpiresAt: time.Now().Add(365 * 24 * time.Hour), + if err != nil { + Log("MAL browser authentication failed: " + err.Error()) + fmt.Printf("MAL authentication failed: %v\n", err) + ExitCurd(fmt.Errorf("MAL authentication failed: %w", err)) } + } else { + // AniList authentication + tokenPath := filepath.Join(os.ExpandEnv(config.StoragePath), "anilist_token.json") + + // Try browser-based OAuth first + fmt.Println("Starting browser-based authentication...") + user.Token, err = authenticateWithBrowser(tokenPath) + + if err != nil { + Log("Browser authentication failed: " + err.Error()) + fmt.Printf("Browser authentication failed: %v\n", err) + fmt.Println("Falling back to manual token entry...") - if err := saveToken(tokenPath, token); err != nil { - ExitCurd(fmt.Errorf("failed to save token: %w", err)) + // Simple CLI fallback + fmt.Println("Please visit: https://anilist.co/api/v2/oauth/authorize?client_id=20686&response_type=token&redirect_uri=http://localhost:8000/oauth/callback") + fmt.Print("Copy and paste your access token here: ") + fmt.Scanln(&user.Token) + + if user.Token == "" { + ExitCurd(fmt.Errorf("no token provided")) + } + + // Save the manually entered token as JSON format + token := &AnilistToken{ + AccessToken: user.Token, + TokenType: "Bearer", + ExpiresIn: 31536000, // AniList tokens are valid for 1 year + ExpiresAt: time.Now().Add(365 * 24 * time.Hour), + } + + if err := saveToken(tokenPath, token); err != nil { + ExitCurd(fmt.Errorf("failed to save token: %w", err)) + } } } diff --git a/internal/curd.go b/internal/curd.go index dca2569..dc44f52 100755 --- a/internal/curd.go +++ b/internal/curd.go @@ -19,6 +19,8 @@ import ( "github.com/gen2brain/beeep" ) +// Global variable to store the last selected category for back navigation +var lastSelectedCategory SelectionOption func EditConfig(configFilePath string) { // Get the user's preferred editor from the EDITOR environment variable @@ -214,6 +216,7 @@ func CurdOut(data interface{}) { func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { // Create update options updateOptions := []SelectionOption{ + {Key: "-2", Label: "<- Back"}, {Key: "CATEGORY", Label: "Change Anime Category"}, {Key: "PROGRESS", Label: "Change Progress"}, {Key: "SCORE", Label: "Add/Change Score"}, @@ -230,6 +233,11 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { ExitCurd(nil) } + // Handle back button + if updateSelection.Key == "-2" { + return + } + // Get user's anime list var animeListOptions []SelectionOption var animeListMapPreview map[string]RofiSelectPreview @@ -367,6 +375,10 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { selectedAnime, err = DynamicSelectPreview(animeListMapPreview, false) } else { + // Add back option + animeListOptions = append([]SelectionOption{ + {Key: "-2", Label: "<- Back"}, + }, animeListOptions...) selectedAnime, err = DynamicSelect(animeListOptions) } if err != nil { @@ -378,6 +390,13 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { ExitCurd(nil) } + // Handle back button - recursively call UpdateAnimeEntry + if selectedAnime.Key == "-2" { + ClearScreen() + UpdateAnimeEntry(userCurdConfig, user) + return + } + animeID, err := strconv.Atoi(selectedAnime.Key) if err != nil { Log(fmt.Sprintf("Failed to convert anime ID: %v", err)) @@ -394,6 +413,7 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { switch updateSelection.Key { case "CATEGORY": categories := []SelectionOption{ + {Key: "-2", Label: "<- Back"}, {Key: "CURRENT", Label: "Currently Watching"}, {Key: "COMPLETED", Label: "Completed"}, {Key: "PAUSED", Label: "On Hold"}, @@ -424,7 +444,14 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { ExitCurd(nil) } - err = UpdateAnimeStatus(user.Token, animeID, categorySelection.Key) + // Handle back button + if categorySelection.Key == "-2" { + ClearScreen() + UpdateAnimeEntry(userCurdConfig, user) + return + } + + err = UpdateProviderAnimeStatus(userCurdConfig, user.Token, animeID, categorySelection.Key) if err != nil { Log(fmt.Sprintf("Failed to update anime status: %v", err)) ExitCurd(fmt.Errorf("Failed to update anime status")) @@ -455,7 +482,7 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { ExitCurd(fmt.Errorf("Failed to convert progress to number")) } - err = UpdateAnimeProgress(user.Token, animeID, progressNum) + err = UpdateProviderAnimeProgress(userCurdConfig, user.Token, animeID, progressNum) if err != nil { Log(fmt.Sprintf("Failed to update anime progress: %v", err)) ExitCurd(fmt.Errorf("Failed to update anime progress")) @@ -468,7 +495,7 @@ func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { } CurdOut(fmt.Sprintf("Current score: %s", currentScore)) - err = RateAnime(user.Token, animeID) + err = RateProviderAnimeWithPrompt(userCurdConfig, user.Token, animeID) if err != nil { Log(fmt.Sprintf("Failed to update anime score: %v", err)) ExitCurd(fmt.Errorf("Failed to update anime score")) @@ -600,10 +627,13 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA input, _ := reader.ReadString('\n') query = strings.TrimSpace(input) } + // Search using provider abstraction if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { + // For image preview, use AniList-specific function for now + // MAL doesn't provide preview in the same way animeMapPreview, err = SearchAnimeAnilistPreview(query, user.Token) } else { - animeOptions, err = SearchAnimeAnilist(query, user.Token) + animeOptions, err = SearchProviderAnime(userCurdConfig, user.Token, query) if err != nil { Log(fmt.Sprintf("Failed to search anime: %v", err)) ExitCurd(fmt.Errorf("Failed to search anime")) @@ -616,6 +646,10 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { anilistSelectedOption, err = DynamicSelectPreview(animeMapPreview, false) } else { + // Add back option + animeOptions = append([]SelectionOption{ + {Key: "-2", Label: "<- Back"}, + }, animeOptions...) anilistSelectedOption, err = DynamicSelect(animeOptions) } @@ -623,6 +657,11 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA ExitCurd(nil) } + // Handle back button - return special marker + if anilistSelectedOption.Key == "-2" { + return SelectionOption{Key: "-2", Label: "← Back"} + } + if err != nil { Log(fmt.Sprintf("No anime available: %v", err)) ExitCurd(fmt.Errorf("No anime available")) @@ -635,6 +674,7 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA // Add category selection before adding to list categories := []SelectionOption{ + {Key: "-2", Label: "<- Back"}, {Key: "CURRENT", Label: "Currently Watching"}, {Key: "COMPLETED", Label: "Completed"}, {Key: "PAUSED", Label: "On Hold"}, @@ -656,27 +696,22 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA ExitCurd(nil) } - err = UpdateAnimeStatus(user.Token, animeID, categorySelection.Key) + // Handle back button - return special marker + if categorySelection.Key == "-2" { + return SelectionOption{Key: "-2", Label: "← Back"} + } + + err = UpdateProviderAnimeStatus(userCurdConfig, user.Token, animeID, categorySelection.Key) if err != nil { Log(fmt.Sprintf("Failed to add anime to list: %v", err)) ExitCurd(fmt.Errorf("Failed to add anime to list")) } // Refresh user's anime list after adding - if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { - anilistUserDataPreview, err := GetUserDataPreview(user.Token, user.Id) - if err != nil { - Log(fmt.Sprintf("Failed to refresh anime list: %v", err)) - ExitCurd(fmt.Errorf("Failed to refresh anime list")) - } - user.AnimeList = ParseAnimeList(anilistUserDataPreview) - } else { - anilistUserData, err := GetUserData(user.Token, user.Id) - if err != nil { - Log(fmt.Sprintf("Failed to refresh anime list: %v", err)) - ExitCurd(fmt.Errorf("Failed to refresh anime list")) - } - user.AnimeList = ParseAnimeList(anilistUserData) + user.AnimeList, err = GetProviderAnimeList(userCurdConfig, user.Token, user.Id) + if err != nil { + Log(fmt.Sprintf("Failed to refresh anime list: %v", err)) + ExitCurd(fmt.Errorf("Failed to refresh anime list")) } return anilistSelectedOption @@ -684,37 +719,28 @@ func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseA func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAnimes *[]Anime) { var err error - var anilistUserData map[string]interface{} - var anilistUserDataPreview map[string]interface{} // Filter anime list based on selected category var animeListOptions []SelectionOption var animeListMapPreview map[string]RofiSelectPreview - // Get user id, username and Anime list - user.Id, user.Username, err = GetAnilistUserID(user.Token) + // Get user id, username using provider abstraction + user.Id, user.Username, err = GetProviderUserID(userCurdConfig, user.Token) if err != nil { Log(fmt.Sprintf("Failed to get user ID: %v", err)) ExitCurd(fmt.Errorf("Failed to get user ID\nYou can reset the token by running `curd -change-token`")) } - // Get the anime list data - if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { - anilistUserDataPreview, err = GetUserDataPreview(user.Token, user.Id) - if err != nil { - Log(fmt.Sprintf("Failed to get user data preview: %v", err)) - ExitCurd(fmt.Errorf("Failed to get user data preview")) - } - user.AnimeList = ParseAnimeList(anilistUserDataPreview) - } else { - anilistUserData, err = GetUserData(user.Token, user.Id) - if err != nil { - Log(fmt.Sprintf("Failed to get user data: %v", err)) - ExitCurd(fmt.Errorf("Failed to get user ID\nYou can reset the token by running `curd -change-token`")) - } - user.AnimeList = ParseAnimeList(anilistUserData) + // Get the anime list data using provider abstraction + user.AnimeList, err = GetProviderAnimeList(userCurdConfig, user.Token, user.Id) + if err != nil { + Log(fmt.Sprintf("Failed to get anime list: %v", err)) + ExitCurd(fmt.Errorf("Failed to get anime list\nYou can reset the token by running `curd -change-token`")) } + // Declare selectedAnilistAnime for use throughout the function + var selectedAnilistAnime *Entry + // If continueLast flag is set, directly get the last watched anime if anime.Ep.ContinueLast { // Get the last anime ID from the curd_id file @@ -745,192 +771,366 @@ func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAni // anime.Ep.Player.PlaybackTime = animePointer.Ep.Player.PlaybackTime // anime.Ep.Resume = true + // Get the anime entry from user's list + selectedAnilistAnime, err = FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anilistID)) + if err != nil { + Log(fmt.Sprintf("Can not find the anime in provider animelist: %v", err)) + ExitCurd(fmt.Errorf("Can not find the anime in provider animelist")) + } + + // Set anime entry + anime.Title = selectedAnilistAnime.Media.Title + anime.TotalEpisodes = selectedAnilistAnime.Media.Episodes + } else { - // Skip category selection if Current flag is set - var categorySelection SelectionOption - if userCurdConfig.CurrentCategory { - categorySelection = SelectionOption{ - Key: "CURRENT", - Label: "Currently Watching", - } - } else { - // Create category selection map - // Get ordered categories - orderedCategories := getOrderedCategories(userCurdConfig) + // Navigation loop: allow going back to category selection from anime selection + categorySelected := false + for !categorySelected { + // Skip category selection if Current flag is set OR if we have a last selected category (back navigation) + var categorySelection SelectionOption + if userCurdConfig.CurrentCategory { + categorySelection = SelectionOption{ + Key: "CURRENT", + Label: "Currently Watching", + } + } else if lastSelectedCategory.Key != "" { + // Use the last selected category (user pressed back from episode playback) + categorySelection = lastSelectedCategory + lastSelectedCategory = SelectionOption{} // Reset after use + CurdOut(fmt.Sprintf("Returning to %s...", categorySelection.Label)) + } else { + // Create category selection map + // Get ordered categories + orderedCategories := getOrderedCategories(userCurdConfig) - // Use DynamicSelect with ordered categories directly - categorySelection, err = DynamicSelect(orderedCategories) + // Use DynamicSelect with ordered categories directly + categorySelection, err = DynamicSelect(orderedCategories) - if err != nil { - Log(fmt.Sprintf("Failed to select category: %v", err)) - ExitCurd(fmt.Errorf("Failed to select category")) - } + if err != nil { + Log(fmt.Sprintf("Failed to select category: %v", err)) + ExitCurd(fmt.Errorf("Failed to select category")) + } - if categorySelection.Key == "-1" { - ExitCurd(nil) - } + if categorySelection.Key == "-1" { + ExitCurd(nil) + } + + // Handle options + if categorySelection.Key == "UPDATE" { + ClearScreen() + UpdateAnimeEntry(userCurdConfig, user) + // Don't exit - let the loop continue to allow going back + ClearScreen() + continue + } else if categorySelection.Key == "UNTRACKED" { + ClearScreen() + WatchUntracked(userCurdConfig) + // Don't exit - let the loop continue to allow going back + ClearScreen() + continue + } else if categorySelection.Key == "CONTINUE_LAST" { + anime.Ep.ContinueLast = true + } - // Handle options - if categorySelection.Key == "UPDATE" { - ClearScreen() - UpdateAnimeEntry(userCurdConfig, user) - ExitCurd(nil) - } else if categorySelection.Key == "UNTRACKED" { ClearScreen() - WatchUntracked(userCurdConfig) - } else if categorySelection.Key == "CONTINUE_LAST" { - anime.Ep.ContinueLast = true } - ClearScreen() - } + // Save the category selection for potential back navigation + lastSelectedCategory = categorySelection - if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { - animeListMapPreview = make(map[string]RofiSelectPreview) - for _, entry := range getEntriesByCategory(user.AnimeList, categorySelection.Key) { - title := entry.Media.Title.English - if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { - title = entry.Media.Title.Romaji + if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { + animeListMapPreview = make(map[string]RofiSelectPreview) + for _, entry := range getEntriesByCategory(user.AnimeList, categorySelection.Key) { + title := entry.Media.Title.English + if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { + title = entry.Media.Title.Romaji + } + animeListMapPreview[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{ + Title: title, + CoverImage: entry.CoverImage, + } } - animeListMapPreview[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{ - Title: title, - CoverImage: entry.CoverImage, + } else { + animeListOptions = make([]SelectionOption, 0) + for _, entry := range getEntriesByCategory(user.AnimeList, categorySelection.Key) { + title := entry.Media.Title.English + if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { + title = entry.Media.Title.Romaji + } + animeListOptions = append(animeListOptions, SelectionOption{ + Key: strconv.Itoa(entry.Media.ID), + Label: title, + }) } } - } else { - animeListOptions = make([]SelectionOption, 0) - for _, entry := range getEntriesByCategory(user.AnimeList, categorySelection.Key) { - title := entry.Media.Title.English - if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { - title = entry.Media.Title.Romaji - } + + // Anime selection with back option + var anilistSelectedOption SelectionOption + if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { + anilistSelectedOption, err = DynamicSelectPreview(animeListMapPreview, true) + } else { + // Add "Back" option at the top + animeListOptions = append([]SelectionOption{ + { + Key: "-2", + Label: "← Back", + }, + }, animeListOptions...) + + // Add "Add new anime" option to the slice animeListOptions = append(animeListOptions, SelectionOption{ - Key: strconv.Itoa(entry.Media.ID), - Label: title, + Key: "add_new", + Label: "Add new anime", }) + + anilistSelectedOption, err = DynamicSelect(animeListOptions) } - } - } - var anilistSelectedOption SelectionOption - var selectedAllanimeAnime SelectionOption - var userQuery string + if err != nil { + Log(fmt.Sprintf("Error selecting anime: %v", err)) + ExitCurd(fmt.Errorf("Error selecting anime")) + } - if anime.Ep.ContinueLast { - // Get the last watched anime ID from the curd_id file - curdIDPath := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_id") - curdIDBytes, err := os.ReadFile(curdIDPath) - if err != nil { - Log(fmt.Sprintf("Error reading curd_id file: %v", err)) - ExitCurd(fmt.Errorf("Error reading curd_id file")) - } + if anilistSelectedOption.Key == "-1" { + ExitCurd(nil) + } - lastWatchedID, err := strconv.Atoi(strings.TrimSpace(string(curdIDBytes))) - if err != nil { - Log(fmt.Sprintf("Error converting curd_id to integer: %v", err)) - ExitCurd(fmt.Errorf("Error converting curd_id to integer")) - } + // Handle back button + if anilistSelectedOption.Key == "-2" { + ClearScreen() + // Clear the last selected category so user can choose again + lastSelectedCategory = SelectionOption{} + continue // Go back to category selection + } - anime.AnilistId = lastWatchedID - anilistSelectedOption.Key = strconv.Itoa(lastWatchedID) - } else { - // Select anime to watch (Anilist) - var err error - if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { - anilistSelectedOption, err = DynamicSelectPreview(animeListMapPreview, true) - } else { - // Add "Add new anime" option to the slice - animeListOptions = append(animeListOptions, SelectionOption{ - Key: "add_new", - Label: "Add new anime", - }) + if anilistSelectedOption.Label == "add_new" || anilistSelectedOption.Key == "add_new" { + anilistSelectedOption = AddNewAnime(userCurdConfig, anime, user, databaseAnimes) + // If user pressed back in AddNewAnime, go back to category selection + if anilistSelectedOption.Key == "-2" { + ClearScreen() + // Clear the last selected category so user can choose again + lastSelectedCategory = SelectionOption{} + continue + } + } - anilistSelectedOption, err = DynamicSelect(animeListOptions) - } - if err != nil { - Log(fmt.Sprintf("Error selecting anime: %v", err)) - ExitCurd(fmt.Errorf("Error selecting anime")) - } + anime.AnilistId, err = strconv.Atoi(anilistSelectedOption.Key) + if err != nil { + Log(fmt.Sprintf("Error converting Anilist ID: %v", err)) + ExitCurd(fmt.Errorf("Error converting Anilist ID")) + } - Log(anilistSelectedOption) + // Clear screen after anime selection + ClearScreen() - if anilistSelectedOption.Key == "-1" { - ExitCurd(nil) - } + // Successfully selected an anime, exit the loop + categorySelected = true - if anilistSelectedOption.Label == "add_new" || anilistSelectedOption.Key == "add_new" { - anilistSelectedOption = AddNewAnime(userCurdConfig, anime, user, databaseAnimes) - } + // Store the selected option for later use + selectedAnilistAnime, err = FindAnimeByAnilistID(user.AnimeList, anilistSelectedOption.Key) + if err != nil { + Log(fmt.Sprintf("Can not find the anime in anilist animelist: %v", err)) + ExitCurd(fmt.Errorf("Can not find the anime in anilist animelist")) + } + + // Set anime entry + anime.Title = selectedAnilistAnime.Media.Title + anime.TotalEpisodes = selectedAnilistAnime.Media.Episodes + anime.Ep.Number = selectedAnilistAnime.Progress + 1 - anime.AnilistId, err = strconv.Atoi(anilistSelectedOption.Key) - if err != nil { - Log(fmt.Sprintf("Error converting Anilist ID: %v", err)) - ExitCurd(fmt.Errorf("Error converting Anilist ID")) } } + + // Wrap the rest in a function with panic recovery to handle back navigation + func() { + defer func() { + if r := recover(); r != nil { + if r == "BACK_TO_ANIME_SELECTION" { + // Restart the SetupCurd to go back to anime selection + SetupCurd(userCurdConfig, anime, user, databaseAnimes) + return + } + // Re-panic if it's not our signal + panic(r) + } + }() + + var selectedAllanimeAnime SelectionOption + var userQuery string + + // After the navigation loop, anime.AnilistId and anime.Title are already set // Find anime in Local history animePointer := LocalFindAnime(*databaseAnimes, anime.AnilistId, "") - // Get anime entry - selectedAnilistAnime, err := FindAnimeByAnilistID(user.AnimeList, anilistSelectedOption.Key) - if err != nil { - Log(fmt.Sprintf("Can not find the anime in anilist animelist: %v", err)) - ExitCurd(fmt.Errorf("Can not find the anime in anilist animelist")) - } - - // Set anime entry - anime.Title = selectedAnilistAnime.Media.Title - anime.TotalEpisodes = selectedAnilistAnime.Media.Episodes - anime.Ep.Number = selectedAnilistAnime.Progress + 1 - var animeList []SelectionOption userQuery = anime.Title.Romaji + var animeList []SelectionOption // if anime not found in database, find it in animeList if animePointer == nil { - Log("Anime not found in database, searching in animeList...") - // Get Anime list (All anime) - Log(fmt.Sprintf("Searching for anime with query: %s, SubOrDub: %s", userQuery, userCurdConfig.SubOrDub)) + // Loop to allow going back from streaming source selection + streamingSourceSelected := false + for !streamingSourceSelected { + Log("Anime not found in database, searching in animeList...") + // Get Anime list (All anime) + Log(fmt.Sprintf("Searching for anime with query: %s, SubOrDub: %s", userQuery, userCurdConfig.SubOrDub)) + + animeList, err = SearchAnime(string(userQuery), userCurdConfig.SubOrDub) + if err != nil { + Log(fmt.Sprintf("Failed to select anime: %v", err)) + ExitCurd(fmt.Errorf("Failed to select anime")) + } + if len(animeList) == 0 { + ExitCurd(fmt.Errorf("No results found.")) + } - animeList, err = SearchAnime(string(userQuery), userCurdConfig.SubOrDub) - if err != nil { - Log(fmt.Sprintf("Failed to select anime: %v", err)) - ExitCurd(fmt.Errorf("Failed to select anime")) - } - if len(animeList) == 0 { - ExitCurd(fmt.Errorf("No results found.")) - } + // Try to find anime automatically using multiple matching strategies + // Set timeout for auto-matching (15 seconds) + matchingStartTime := time.Now() + matchingTimeout := 15 * time.Second + + found := false + requiresConfirmation := false - // find anime in animeList by searching through the options - targetLabel := fmt.Sprintf("%v (%d episodes)", userQuery, selectedAnilistAnime.Media.Episodes) - found := false + // Strategy 1: Exact match with episode count (HIGH CONFIDENCE) + targetLabel := fmt.Sprintf("%v (%d episodes)", userQuery, selectedAnilistAnime.Media.Episodes) for i, option := range animeList { Log(fmt.Sprintf("Checking option %d: Key='%s', Label='%s'", i, option.Key, option.Label)) if option.Label == targetLabel { anime.AllanimeId = option.Key Log(fmt.Sprintf("Found exact match! Setting AllanimeId to: %s", anime.AllanimeId)) found = true + requiresConfirmation = false // High confidence, no confirmation needed break } } + // Check timeout after Strategy 1 + if time.Since(matchingStartTime) > matchingTimeout { + Log("Auto-matching timeout after Strategy 1. Switching to manual selection.") + found = false + } + + // Strategy 2: Check if option label starts with the anime name (MEDIUM CONFIDENCE) + if !found && time.Since(matchingStartTime) <= matchingTimeout { + Log("Trying partial name match...") + normalizedQuery := strings.ToLower(strings.TrimSpace(userQuery)) + for _, option := range animeList { + normalizedLabel := strings.ToLower(option.Label) + // Check if the label starts with the query (higher confidence) + if strings.HasPrefix(normalizedLabel, normalizedQuery) { + anime.AllanimeId = option.Key + Log(fmt.Sprintf("Found prefix match! Setting AllanimeId to: %s (matched on '%s')", anime.AllanimeId, option.Label)) + found = true + requiresConfirmation = false // Prefix match is fairly confident + break + } + } + } + + // Check timeout after Strategy 2 + if time.Since(matchingStartTime) > matchingTimeout { + Log("Auto-matching timeout after Strategy 2. Switching to manual selection.") + found = false + } + + // Strategy 3: Check if query is contained anywhere in label (LOWER CONFIDENCE) + if !found && time.Since(matchingStartTime) <= matchingTimeout { + Log("Trying contains match...") + normalizedQuery := strings.ToLower(strings.TrimSpace(userQuery)) + for _, option := range animeList { + normalizedLabel := strings.ToLower(option.Label) + if strings.Contains(normalizedLabel, normalizedQuery) { + anime.AllanimeId = option.Key + Log(fmt.Sprintf("Found contains match! Setting AllanimeId to: %s (matched on '%s')", anime.AllanimeId, option.Label)) + found = true + requiresConfirmation = true // Lower confidence, ask user to confirm + break + } + } + } + + // Check timeout after Strategy 3 + if time.Since(matchingStartTime) > matchingTimeout { + Log("Auto-matching timeout after Strategy 3. Switching to manual selection.") + found = false + } + + // Strategy 4: Try with English title if available + if !found && anime.Title.English != "" && time.Since(matchingStartTime) <= matchingTimeout { + Log(fmt.Sprintf("Trying with English title: %s", anime.Title.English)) + normalizedEnglish := strings.ToLower(strings.TrimSpace(anime.Title.English)) + for _, option := range animeList { + normalizedLabel := strings.ToLower(option.Label) + if strings.HasPrefix(normalizedLabel, normalizedEnglish) { + anime.AllanimeId = option.Key + Log(fmt.Sprintf("Found match with English title! Setting AllanimeId to: %s (matched on '%s')", anime.AllanimeId, option.Label)) + found = true + requiresConfirmation = false // English title prefix is confident + break + } else if strings.Contains(normalizedLabel, normalizedEnglish) { + anime.AllanimeId = option.Key + Log(fmt.Sprintf("Found partial match with English title! Setting AllanimeId to: %s (matched on '%s')", anime.AllanimeId, option.Label)) + found = true + requiresConfirmation = true // Contains match needs confirmation + break + } + } + } + + // Check timeout after Strategy 4 + if time.Since(matchingStartTime) > matchingTimeout { + Log(fmt.Sprintf("Auto-matching timeout (%.2f seconds). Switching to manual selection.", time.Since(matchingStartTime).Seconds())) + CurdOut(fmt.Sprintf("Auto-matching took too long (>%.0f seconds), please select manually", matchingTimeout.Seconds())) + found = false + anime.AllanimeId = "" // Clear any partial match + } + + // If automatic matching succeeded and doesn't require confirmation, exit the loop + if found && !requiresConfirmation && anime.AllanimeId != "" { + Log(fmt.Sprintf("Auto-match successful! Using AllanimeId: %s", anime.AllanimeId)) + streamingSourceSelected = true + continue // Exit the streaming source selection loop + } + + // If automatic matching failed, require manual selection if !found { - Log(fmt.Sprintf("No exact match found for label '%s'. Will require manual selection.", targetLabel)) + Log(fmt.Sprintf("No automatic match found for '%s'. Will require manual selection.", userQuery)) } - // If unable to get Allanime id automatically get manually - if anime.AllanimeId == "" { - CurdOut("Failed to automatically select anime") + // Show manual selection if: no match found OR match requires confirmation + if anime.AllanimeId == "" || requiresConfirmation { + if requiresConfirmation { + Log("Match found but confidence is low. Showing options for user confirmation.") + CurdOut(fmt.Sprintf("Found possible match: %s", anime.AllanimeId)) + CurdOut("Please confirm or select correct anime:") + } else { + CurdOut("Failed to automatically select anime") + } + // Add back option + animeList = append([]SelectionOption{ + {Key: "-2", Label: "<- Back"}, + }, animeList...) + selectedAllanimeAnime, err := DynamicSelect(animeList) if selectedAllanimeAnime.Key == "-1" { ExitCurd(nil) } + // Handle back button - go back to anime selection + if selectedAllanimeAnime.Key == "-2" { + ClearScreen() + // Signal to restart anime selection + panic("BACK_TO_ANIME_SELECTION") + } + if err != nil { ExitCurd(fmt.Errorf("No anime available")) } anime.AllanimeId = selectedAllanimeAnime.Key + streamingSourceSelected = true } + } // End of streaming source selection loop } else { // if anime found in database, use it anime.AllanimeId = animePointer.AllanimeId @@ -1019,6 +1219,40 @@ func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAni } } + // Check if anime is completed BEFORE setting episode number + if anime.TotalEpisodes > 0 && selectedAnilistAnime.Progress >= anime.TotalEpisodes { + CurdOut(fmt.Sprintf("You've completed all %d episodes of this anime!", anime.TotalEpisodes)) + var answer string + if userCurdConfig.RofiSelection { + userInput, err := GetUserInputFromRofi("Would you like to rewatch from episode 1? (y/n)") + if err != nil { + Log("Error getting user input: " + err.Error()) + ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) + } + answer = userInput + } else { + fmt.Printf("Would you like to rewatch from episode 1? (y/n)\n") + fmt.Scanln(&answer) + } + if answer == "y" { + anime.Ep.Number = 1 + anime.Rewatching = true + + // Reset progress on MAL/AniList to 0 for rewatching + CurdOut("Resetting progress to start rewatching...") + err := UpdateProviderAnimeProgress(userCurdConfig, user.Token, anime.AnilistId, 0) + if err != nil { + Log(fmt.Sprintf("Error resetting progress: %v", err)) + CurdOut("Warning: Failed to reset progress on your list") + } else { + CurdOut("Progress reset successfully!") + } + } else { + CurdOut("Returning to anime selection...") + panic("BACK_TO_ANIME_SELECTION") + } + } + if anime.TotalEpisodes == 0 { // If failed to get anime data CurdOut("Failed to get anime data. Attempting to retrieve from anime list.") animeList, err := SearchAnime(string(userQuery), userCurdConfig.SubOrDub) @@ -1056,7 +1290,7 @@ func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAni } else { anime.Ep.Number = selectedAnilistAnime.Progress + 1 } - } else if anime.TotalEpisodes < anime.Ep.Number { // Handle weird cases + } else if anime.TotalEpisodes > 0 && anime.TotalEpisodes < anime.Ep.Number { // Handle weird cases where episode > total Log(fmt.Sprintf("Weird case: anime.TotalEpisodes < anime.Ep.Number: %v < %v", anime.TotalEpisodes, anime.Ep.Number)) var answer string if userCurdConfig.RofiSelection { @@ -1076,6 +1310,8 @@ func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAni anime.Ep.Number = anime.TotalEpisodes } } + + }() // Close the anonymous function with defer/recover for back navigation } // CreateOrWriteTokenFile creates the token file if it doesn't exist and writes the token to it @@ -1303,6 +1539,7 @@ func NextEpisodePromptCLI(userCurdConfig *CurdConfig) bool { // Create options for the selection - no "quit" option since it's built into selection menu options := []SelectionOption{ {Key: "yes", Label: fmt.Sprintf("Yes, continue to episode %d", nextEpisodeNum)}, + {Key: "-2", Label: "<- Back"}, } // Use DynamicSelect for CLI mode @@ -1320,6 +1557,12 @@ func NextEpisodePromptCLI(userCurdConfig *CurdConfig) bool { return false } + // Handle back button + if selectedOption.Key == "-2" { + CurdOut("Going back...") + return false + } + return selectedOption.Key == "yes" } @@ -1342,6 +1585,7 @@ func NextEpisodePromptContinuous(userCurdConfig *CurdConfig, databaseFile string // Create options for the selection - no "quit" option since it's built into selection menu options := []SelectionOption{ {Key: "yes", Label: "Yes, start next episode now"}, + {Key: "-2", Label: "<- Back"}, } // Use DynamicSelect for CLI mode @@ -1355,14 +1599,14 @@ func NextEpisodePromptContinuous(userCurdConfig *CurdConfig, databaseFile string if selectedOption.Key == "-1" { // User selected to quit via the built-in quit option - + // Check completion percentage percentageWatched := PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration) - + if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete { // Episode is considered completed, mark it and update progress anime.Ep.IsCompleted = true - + // Update local database err = LocalUpdateAnime(databaseFile, anime.AnilistId, anime.AllanimeId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, ConvertSecondsToMinutes(anime.Ep.Duration), GetAnimeName(*anime)) if err != nil { @@ -1380,17 +1624,57 @@ func NextEpisodePromptContinuous(userCurdConfig *CurdConfig, databaseFile string } }() } - + CurdOut(fmt.Sprintf("Episode completed (%.1f%% watched). Exiting.", percentageWatched)) } else { CurdOut(fmt.Sprintf("Episode not completed (%.1f%% watched). Exiting.", percentageWatched)) } - + ExitMPV(anime.Ep.Player.SocketPath) ExitCurd(nil) return } + if selectedOption.Key == "-2" { + // User selected to go back to main menu + + // Check completion percentage + percentageWatched := PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration) + + if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete { + // Episode is considered completed, mark it and update progress + anime.Ep.IsCompleted = true + + // Update local database + err = LocalUpdateAnime(databaseFile, anime.AnilistId, anime.AllanimeId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, ConvertSecondsToMinutes(anime.Ep.Duration), GetAnimeName(*anime)) + if err != nil { + Log("Error updating local database on back: " + err.Error()) + } + + // Update progress if not rewatching + if !anime.Rewatching { + err = UpdateAnimeProgress(userToken, anime.AnilistId, anime.Ep.Number) + if err != nil { + Log("Error updating progress on back: " + err.Error()) + } else { + CurdOut(fmt.Sprintf("Episode marked as completed! Progress updated: %d", anime.Ep.Number)) + } + // Give it a moment to update + time.Sleep(500 * time.Millisecond) + } + + CurdOut(fmt.Sprintf("Episode completed (%.1f%% watched).", percentageWatched)) + } else { + CurdOut(fmt.Sprintf("Episode not completed (%.1f%% watched).", percentageWatched)) + } + + ExitMPV(anime.Ep.Player.SocketPath) + + // Signal that we want to go back to anime selection + // We'll use panic with a special recovery mechanism + panic("BACK_TO_ANIME_SELECTION") + } + if selectedOption.Key == "yes" { // User wants to start next episode immediately anime.Ep.IsCompleted = true @@ -1448,6 +1732,7 @@ func NextEpisodePromptRofi(userCurdConfig *CurdConfig) bool { // Create options for the selection options := []SelectionOption{ {Key: "yes", Label: fmt.Sprintf("Yes, start episode %d", nextEpisodeNum)}, + {Key: "-2", Label: "<- Back"}, } // Use DynamicSelect for Rofi mode @@ -1459,6 +1744,11 @@ func NextEpisodePromptRofi(userCurdConfig *CurdConfig) bool { Log(fmt.Sprintf("Rofi User Selected Key: '%s', Label: '%s'", selectedOption.Key, selectedOption.Label)) + // Handle back button - return false to stop playback + if selectedOption.Key == "-2" { + return false + } + return selectedOption.Key == "yes" } diff --git a/internal/localTracking.go b/internal/localTracking.go index 399c5cc..8bc3ef4 100644 --- a/internal/localTracking.go +++ b/internal/localTracking.go @@ -278,6 +278,11 @@ func WatchUntracked(userCurdConfig *CurdConfig) { ExitCurd(fmt.Errorf("No results found.")) } + // Add back option + animeList = append([]SelectionOption{ + {Key: "-2", Label: "<- Back"}, + }, animeList...) + // Select anime from search results selectedAnime, err := DynamicSelect(animeList) if err != nil { @@ -289,6 +294,11 @@ func WatchUntracked(userCurdConfig *CurdConfig) { ExitCurd(nil) } + // Handle back button + if selectedAnime.Key == "-2" { + return + } + anime.AllanimeId = selectedAnime.Key anime.Title.English = selectedAnime.Label diff --git a/internal/mal.go b/internal/mal.go new file mode 100644 index 0000000..5db2113 --- /dev/null +++ b/internal/mal.go @@ -0,0 +1,577 @@ +package internal + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// MAL API endpoints +const ( + MALBaseURL = "https://api.myanimelist.net/v2" + MALAuthURL = "https://myanimelist.net/v1/oauth2/authorize" + MALTokenURL = "https://myanimelist.net/v1/oauth2/token" + MALRedirectURI = "http://localhost:8080/callback" // Change to your registered redirect URI +) + +// MALTokenResponse represents the OAuth token response from MAL +type MALTokenResponse struct { + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresAt time.Time `json:"expires_at"` +} + +// MALUser represents the authenticated MAL user +type MALUser struct { + ID int `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + Gender string `json:"gender"` + Birthday string `json:"birthday"` + Location string `json:"location"` + JoinedAt string `json:"joined_at"` +} + +// MALAnimeListStatus represents the user's anime list status +type MALAnimeListStatus struct { + Status string `json:"status"` + Score int `json:"score"` + NumEpisodesWatched int `json:"num_episodes_watched"` + IsRewatching bool `json:"is_rewatching"` + UpdatedAt string `json:"updated_at"` + Priority int `json:"priority"` + NumTimesRewatched int `json:"num_times_rewatched"` + ReWatchValue int `json:"rewatch_value"` + Tags []string `json:"tags"` + Comments string `json:"comments"` +} + +// MALAnime represents an anime from MAL API +type MALAnime struct { + ID int `json:"id"` + Title string `json:"title"` + MainPicture MALPicture `json:"main_picture"` + AlternativeTitles MALAlternativeTitles `json:"alternative_titles"` + NumEpisodes int `json:"num_episodes"` + Status string `json:"status"` + MyListStatus *MALAnimeListStatus `json:"my_list_status"` +} + +// MALPicture represents anime cover images +type MALPicture struct { + Medium string `json:"medium"` + Large string `json:"large"` +} + +// MALAlternativeTitles represents alternative titles +type MALAlternativeTitles struct { + Synonyms []string `json:"synonyms"` + En string `json:"en"` + Ja string `json:"ja"` +} + +// MALAnimeListResponse represents the anime list API response +type MALAnimeListResponse struct { + Data []MALAnimeNode `json:"data"` + Paging MALPaging `json:"paging"` +} + +// MALAnimeNode wraps an anime with its node structure +type MALAnimeNode struct { + Node MALAnime `json:"node"` +} + +// MALPaging contains pagination information +type MALPaging struct { + Next string `json:"next"` + Previous string `json:"previous"` +} + +// GenerateCodeVerifier generates a random code verifier for PKCE (43-128 characters) +func GenerateCodeVerifier() (string, error) { + b := make([]byte, 64) // 64 bytes = 86 base64 characters (within 43-128 range) + _, err := rand.Read(b) + if err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + + // URL-safe base64 encoding without padding + verifier := base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b) + return verifier, nil +} + +// GenerateCodeChallenge generates a code challenge from the verifier +// MAL supports "plain" method, so we just return the verifier +func GenerateCodeChallenge(verifier string) string { + return verifier +} + +// GenerateState generates a random state parameter for CSRF protection +func GenerateState() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", fmt.Errorf("failed to generate state: %w", err) + } + return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(b), nil +} + +// GetMALAuthorizationURL generates the OAuth authorization URL +func GetMALAuthorizationURL(clientID, redirectURI, state, codeChallenge string) string { + params := url.Values{} + params.Add("response_type", "code") + params.Add("client_id", clientID) + params.Add("redirect_uri", redirectURI) + params.Add("state", state) + params.Add("code_challenge", codeChallenge) + params.Add("code_challenge_method", "plain") + + return fmt.Sprintf("%s?%s", MALAuthURL, params.Encode()) +} + +// ExchangeCodeForToken exchanges the authorization code for access token +func ExchangeCodeForToken(clientID, clientSecret, code, redirectURI, codeVerifier string) (*MALTokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "authorization_code") + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("code", code) + data.Set("redirect_uri", redirectURI) + data.Set("code_verifier", codeVerifier) + + req, err := http.NewRequest("POST", MALTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create token request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to exchange code for token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp MALTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse token response: %w", err) + } + + // Calculate expiration time + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + return &tokenResp, nil +} + +// RefreshMALToken refreshes an expired access token +func RefreshMALToken(clientID, clientSecret, refreshToken string) (*MALTokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("client_id", clientID) + data.Set("client_secret", clientSecret) + data.Set("refresh_token", refreshToken) + + req, err := http.NewRequest("POST", MALTokenURL, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("failed to create refresh request: %w", err) + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to refresh token: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read refresh response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) + } + + var tokenResp MALTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("failed to parse refresh response: %w", err) + } + + // Calculate expiration time + tokenResp.ExpiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) + + return &tokenResp, nil +} + +// GetMALUserInfo retrieves the authenticated user's information +func GetMALUserInfo(accessToken string) (*MALUser, error) { + url := fmt.Sprintf("%s/users/@me", MALBaseURL) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create user info request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get user info: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read user info response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get user info with status %d: %s", resp.StatusCode, string(body)) + } + + var user MALUser + if err := json.Unmarshal(body, &user); err != nil { + return nil, fmt.Errorf("failed to parse user info: %w", err) + } + + return &user, nil +} + +// GetMALAnimeList retrieves the user's anime list +func GetMALAnimeList(accessToken, status string, limit, offset int) (*MALAnimeListResponse, error) { + baseURL := fmt.Sprintf("%s/users/@me/animelist", MALBaseURL) + + params := url.Values{} + params.Add("fields", "my_list_status{status,score,num_episodes_watched,is_rewatching},num_episodes,status,alternative_titles") + if status != "" && status != "all" { + params.Add("status", status) + } + if limit > 0 { + params.Add("limit", strconv.Itoa(limit)) + } + if offset > 0 { + params.Add("offset", strconv.Itoa(offset)) + } + + requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create anime list request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get anime list: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read anime list response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get anime list with status %d: %s", resp.StatusCode, string(body)) + } + + var listResp MALAnimeListResponse + if err := json.Unmarshal(body, &listResp); err != nil { + return nil, fmt.Errorf("failed to parse anime list: %w", err) + } + + return &listResp, nil +} + +// SearchMALAnime searches for anime on MAL +func SearchMALAnime(accessToken, query string, limit int) (*MALAnimeListResponse, error) { + baseURL := fmt.Sprintf("%s/anime", MALBaseURL) + + params := url.Values{} + params.Add("q", query) + params.Add("fields", "alternative_titles,num_episodes,status") + if limit > 0 { + params.Add("limit", strconv.Itoa(limit)) + } else { + params.Add("limit", "10") + } + + requestURL := fmt.Sprintf("%s?%s", baseURL, params.Encode()) + + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create search request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to search anime: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read search response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("search failed with status %d: %s", resp.StatusCode, string(body)) + } + + var searchResp MALAnimeListResponse + if err := json.Unmarshal(body, &searchResp); err != nil { + return nil, fmt.Errorf("failed to parse search results: %w", err) + } + + return &searchResp, nil +} + +// UpdateMALAnimeStatus updates the anime list status for a specific anime +func UpdateMALAnimeStatus(accessToken string, animeID int, status string, score, episodesWatched int) error { + requestURL := fmt.Sprintf("%s/anime/%d/my_list_status", MALBaseURL, animeID) + + data := url.Values{} + if status != "" { + data.Set("status", status) + } + // Allow score to be set to any value >= 0 (0 means remove score) + if score >= 0 { + data.Set("score", strconv.Itoa(score)) + } + // Only set episodes if it's a valid value (>= 0) + if episodesWatched >= 0 { + data.Set("num_watched_episodes", strconv.Itoa(episodesWatched)) + } + + Log(fmt.Sprintf("MAL Update Request - URL: %s, Data: %s", requestURL, data.Encode())) + + req, err := http.NewRequest("PUT", requestURL, strings.NewReader(data.Encode())) + if err != nil { + return fmt.Errorf("failed to create update request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to update anime status: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read update response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + Log(fmt.Sprintf("MAL Update Failed - Status: %d, Body: %s", resp.StatusCode, string(body))) + return fmt.Errorf("update failed with status %d: %s", resp.StatusCode, string(body)) + } + + Log(fmt.Sprintf("MAL Update Success - Response: %s", string(body))) + return nil +} + +// DeleteMALAnimeFromList removes an anime from the user's list +func DeleteMALAnimeFromList(accessToken string, animeID int) error { + requestURL := fmt.Sprintf("%s/anime/%d/my_list_status", MALBaseURL, animeID) + + req, err := http.NewRequest("DELETE", requestURL, nil) + if err != nil { + return fmt.Errorf("failed to create delete request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to delete anime: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNotFound { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("delete failed with status %d: %s", resp.StatusCode, string(body)) + } + + return nil +} + +// GetMALAnimeDetails retrieves detailed information about a specific anime +func GetMALAnimeDetails(accessToken string, animeID int) (*MALAnime, error) { + requestURL := fmt.Sprintf("%s/anime/%d?fields=alternative_titles,num_episodes,status,my_list_status", MALBaseURL, animeID) + + req, err := http.NewRequest("GET", requestURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to create anime details request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to get anime details: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read anime details response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get anime details with status %d: %s", resp.StatusCode, string(body)) + } + + var anime MALAnime + if err := json.Unmarshal(body, &anime); err != nil { + return nil, fmt.Errorf("failed to parse anime details: %w", err) + } + + return &anime, nil +} + +// ConvertMALStatusToAnilistStatus converts MAL status to AniList status format +func ConvertMALStatusToAnilistStatus(malStatus string) string { + switch malStatus { + case "watching": + return "CURRENT" + case "completed": + return "COMPLETED" + case "on_hold": + return "PAUSED" + case "dropped": + return "DROPPED" + case "plan_to_watch": + return "PLANNING" + default: + return "CURRENT" + } +} + +// ConvertAnilistStatusToMAL converts AniList status to MAL status format +func ConvertAnilistStatusToMAL(anilistStatus string) string { + switch anilistStatus { + case "CURRENT": + return "watching" + case "COMPLETED": + return "completed" + case "PAUSED": + return "on_hold" + case "DROPPED": + return "dropped" + case "PLANNING": + return "plan_to_watch" + default: + return "watching" + } +} + +// ParseMALAnimeList converts MAL anime list response to internal AnimeList format +func ParseMALAnimeList(malList *MALAnimeListResponse) AnimeList { + animeList := AnimeList{} + + for _, node := range malList.Data { + anime := node.Node + + // Create entry + entry := Entry{ + Media: Media{ + ID: anime.ID, + Episodes: anime.NumEpisodes, + Duration: 24, // MAL doesn't provide duration, default to 24 minutes + Title: AnimeTitle{ + Romaji: anime.Title, + English: anime.AlternativeTitles.En, + Japanese: anime.AlternativeTitles.Ja, + }, + }, + Score: 0, + } + + // If anime has list status, add progress and score + if anime.MyListStatus != nil { + entry.Progress = anime.MyListStatus.NumEpisodesWatched + entry.Score = float64(anime.MyListStatus.Score) + entry.Status = ConvertMALStatusToAnilistStatus(anime.MyListStatus.Status) + entry.CoverImage = anime.MainPicture.Large + + // Add to appropriate category + switch anime.MyListStatus.Status { + case "watching": + animeList.Watching = append(animeList.Watching, entry) + case "completed": + animeList.Completed = append(animeList.Completed, entry) + case "on_hold": + animeList.Paused = append(animeList.Paused, entry) + case "dropped": + animeList.Dropped = append(animeList.Dropped, entry) + case "plan_to_watch": + animeList.Planning = append(animeList.Planning, entry) + } + + // Check for rewatching + if anime.MyListStatus.IsRewatching { + animeList.Rewatching = append(animeList.Rewatching, entry) + } + } + } + + return animeList +} + +// SearchMALAnimeSimple searches for anime and returns results in SelectionOption format +func SearchMALAnimeSimple(accessToken, query string) ([]SelectionOption, error) { + searchResp, err := SearchMALAnime(accessToken, query, 10) + if err != nil { + return nil, err + } + + var results []SelectionOption + for _, node := range searchResp.Data { + anime := node.Node + title := anime.Title + if anime.AlternativeTitles.En != "" { + title = anime.AlternativeTitles.En + } + + results = append(results, SelectionOption{ + Key: strconv.Itoa(anime.ID), + Label: title, + }) + } + + return results, nil +} \ No newline at end of file diff --git a/internal/provider.go b/internal/provider.go new file mode 100644 index 0000000..a491ab5 --- /dev/null +++ b/internal/provider.go @@ -0,0 +1,297 @@ +package internal + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +// GetProviderToken gets the token for the configured provider +func GetProviderToken(config *CurdConfig) (string, error) { + provider := strings.ToLower(config.AnimeProvider) + + var tokenPath string + if provider == "mal" { + tokenPath = filepath.Join(os.ExpandEnv(config.StoragePath), "mal_token.json") + } else { + tokenPath = filepath.Join(os.ExpandEnv(config.StoragePath), "anilist_token.json") + } + + token, err := GetTokenFromFile(tokenPath) + if err != nil { + return "", err + } + + return token, nil +} + +// GetProviderUserID gets the user ID and username based on provider +func GetProviderUserID(config *CurdConfig, token string) (int, string, error) { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + user, err := GetMALUserInfo(token) + if err != nil { + return 0, "", fmt.Errorf("failed to get MAL user info: %w", err) + } + return user.ID, user.Name, nil + } + + // Default to AniList + return GetAnilistUserID(token) +} + +// GetProviderAnimeList gets anime list from the configured provider +func GetProviderAnimeList(config *CurdConfig, token string, userID int) (AnimeList, error) { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + // Get all anime list statuses from MAL + statuses := []string{"watching", "completed", "on_hold", "dropped", "plan_to_watch"} + var allAnime AnimeList + + for _, status := range statuses { + malList, err := GetMALAnimeList(token, status, 1000, 0) + if err != nil { + return AnimeList{}, fmt.Errorf("failed to get MAL anime list for status %s: %w", status, err) + } + + // Parse and merge + parsedList := ParseMALAnimeList(malList) + allAnime.Watching = append(allAnime.Watching, parsedList.Watching...) + allAnime.Completed = append(allAnime.Completed, parsedList.Completed...) + allAnime.Paused = append(allAnime.Paused, parsedList.Paused...) + allAnime.Dropped = append(allAnime.Dropped, parsedList.Dropped...) + allAnime.Planning = append(allAnime.Planning, parsedList.Planning...) + allAnime.Rewatching = append(allAnime.Rewatching, parsedList.Rewatching...) + } + + return allAnime, nil + } + + // Default to AniList + if config.RofiSelection && config.ImagePreview { + anilistUserData, err := GetUserDataPreview(token, userID) + if err != nil { + return AnimeList{}, fmt.Errorf("failed to get AniList user data preview: %w", err) + } + return ParseAnimeList(anilistUserData), nil + } + + anilistUserData, err := GetUserData(token, userID) + if err != nil { + return AnimeList{}, fmt.Errorf("failed to get AniList user data: %w", err) + } + return ParseAnimeList(anilistUserData), nil +} + +// SearchProviderAnime searches for anime using the configured provider +func SearchProviderAnime(config *CurdConfig, token, query string) ([]SelectionOption, error) { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + return SearchMALAnimeSimple(token, query) + } + + // Default to AniList + return SearchAnimeAnilist(query, token) +} + +// UpdateProviderAnimeProgress updates anime progress on the configured provider +func UpdateProviderAnimeProgress(config *CurdConfig, token string, animeID, progress int) error { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + // Pass -1 for score to skip updating it (only update episodes) + return UpdateMALAnimeStatus(token, animeID, "", -1, progress) + } + + // Default to AniList + return UpdateAnimeProgress(token, animeID, progress) +} + +// UpdateProviderAnimeStatus updates anime status on the configured provider +func UpdateProviderAnimeStatus(config *CurdConfig, token string, animeID int, status string) error { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + malStatus := ConvertAnilistStatusToMAL(status) + // Pass -1 for score and episodes to skip updating them (only update status) + return UpdateMALAnimeStatus(token, animeID, malStatus, -1, -1) + } + + // Default to AniList + return UpdateAnimeStatus(token, animeID, status) +} + +// RateProviderAnime rates an anime on the configured provider +func RateProviderAnime(config *CurdConfig, token string, animeID int, score float64) error { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + // MAL uses 0-10 integer scale + malScore := int(score) + if malScore > 10 { + malScore = 10 + } + return UpdateMALAnimeStatus(token, animeID, "", malScore, -1) + } + + // Default to AniList + return RateAnime(token, animeID) +} + +// RateProviderAnimeWithPrompt prompts the user for a score and rates the anime on the configured provider +func RateProviderAnimeWithPrompt(config *CurdConfig, token string, animeID int) error { + var score float64 + var err error + + if config.RofiSelection { + userInput, err := GetUserInputFromRofi("Enter a score for the anime (0-10)") + if err != nil { + Log(fmt.Sprintf("Failed to get score from Rofi: %v", err)) + return err + } + score, err = strconv.ParseFloat(userInput, 64) + if err != nil { + Log(fmt.Sprintf("Failed to parse score: %v", err)) + return err + } + } else { + CurdOut("Rate this anime (0-10): ") + fmt.Scanln(&score) + } + + Log(fmt.Sprintf("User entered score: %.2f for anime ID: %d", score, animeID)) + + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + // MAL uses 0-10 integer scale + malScore := int(score) + if malScore > 10 { + malScore = 10 + } + if malScore < 0 { + malScore = 0 + } + Log(fmt.Sprintf("Calling UpdateMALAnimeStatus with score=%d for anime ID=%d", malScore, animeID)) + err = UpdateMALAnimeStatus(token, animeID, "", malScore, -1) + if err != nil { + Log(fmt.Sprintf("UpdateMALAnimeStatus failed: %v", err)) + return err + } + CurdOut(fmt.Sprintf("Successfully rated anime (ID: %d) with score: %d", animeID, malScore)) + return nil + } + + // Default to AniList + return RateAnime(token, animeID) +} + +// AddProviderAnimeToWatching adds anime to watching list on the configured provider +func AddProviderAnimeToWatching(config *CurdConfig, token string, animeID int) error { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + return UpdateMALAnimeStatus(token, animeID, "watching", 0, 0) + } + + // Default to AniList + return AddAnimeToWatchingList(animeID, token) +} + +// GetProviderAnimeDetails gets anime details from the configured provider +func GetProviderAnimeDetails(config *CurdConfig, token string, animeID int) (*Anime, error) { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + malAnime, err := GetMALAnimeDetails(token, animeID) + if err != nil { + return nil, fmt.Errorf("failed to get MAL anime details: %w", err) + } + + // Convert MAL anime to internal Anime struct + anime := &Anime{ + AnilistId: animeID, // For MAL, we use MAL ID + MalId: animeID, + TotalEpisodes: malAnime.NumEpisodes, + Title: AnimeTitle{ + Romaji: malAnime.Title, + English: malAnime.AlternativeTitles.En, + Japanese: malAnime.AlternativeTitles.Ja, + }, + CoverImage: malAnime.MainPicture.Large, + } + + // Check if currently airing + anime.IsAiring = malAnime.Status == "currently_airing" + + return anime, nil + } + + // Default to AniList + anime, err := GetAnimeDataByID(animeID, token) + if err != nil { + return nil, err + } + return &anime, nil +} + +// FindProviderAnimeByID finds an anime in the anime list by ID +func FindProviderAnimeByID(config *CurdConfig, list AnimeList, idStr string) (*Entry, error) { + // This function works the same regardless of provider since we normalize the data + return FindAnimeByAnilistID(list, idStr) +} + +// GetProviderAnimeMap gets a map of anime IDs to titles from the anime list +func GetProviderAnimeMap(config *CurdConfig, animeList AnimeList) map[string]string { + // This function works the same regardless of provider since we normalize the data + return GetAnimeMap(animeList) +} + +// GetProviderAnimeMapPreview gets a map with cover images for rofi preview +func GetProviderAnimeMapPreview(config *CurdConfig, animeList AnimeList) map[string]RofiSelectPreview { + // This function works the same regardless of provider since we normalize the data + return GetAnimeMapPreview(animeList) +} + +// GetProviderAnimeMalID gets the MAL ID for an anime +// For MAL provider, the anime ID is already the MAL ID +// For AniList provider, we need to fetch it from AniList +func GetProviderAnimeMalID(config *CurdConfig, animeID int) (int, error) { + provider := strings.ToLower(config.AnimeProvider) + + if provider == "mal" { + // Already using MAL ID + return animeID, nil + } + + // Get MAL ID from AniList + return GetAnimeMalID(animeID) +} + +// ConvertIDIfNeeded converts between provider IDs if needed +// This is useful when working with external APIs like MAL ID for filler lists +func ConvertIDIfNeeded(config *CurdConfig, animeID int, targetProvider string) (int, error) { + currentProvider := strings.ToLower(config.AnimeProvider) + targetProvider = strings.ToLower(targetProvider) + + if currentProvider == targetProvider { + return animeID, nil + } + + if currentProvider == "anilist" && targetProvider == "mal" { + return GetAnimeMalID(animeID) + } + + if currentProvider == "mal" && targetProvider == "anilist" { + // There's no direct MAL to AniList conversion in the current codebase + // This would require querying AniList with the MAL ID + return 0, fmt.Errorf("MAL to AniList ID conversion not implemented") + } + + return animeID, nil +} \ No newline at end of file