Repository: Wraient/curd Branch: main Commit: 6a0c2b9a2667 Files: 898 Total size: 19.6 MB Directory structure: gitextract_bu9ojwvr/ ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── bug_report.md │ └── workflows/ │ ├── ci-cd.yml │ └── update-aur.yml ├── Build/ │ ├── buildlinux │ ├── buildlinux-arm64 │ ├── buildlinux-x86_64 │ ├── buildmac │ ├── buildmac-arm64 │ ├── buildmac-x86_64 │ ├── buildwindows │ ├── buildwindows-arm64 │ ├── buildwindows-x86_64 │ ├── curd-windows-build.iss │ ├── release │ └── requirements.txt ├── LICENSE ├── PKGBUILD ├── README.md ├── VERSION.txt ├── cmd/ │ └── curd/ │ └── main.go ├── flake.nix ├── go.mod ├── go.sum ├── internal/ │ ├── anilist.go │ ├── anilist_cache.go │ ├── anime_list.go │ ├── aniskip.go │ ├── config.go │ ├── curd.go │ ├── discord.go │ ├── episode_list.go │ ├── episode_url.go │ ├── filler_list.go │ ├── globals.go │ ├── http_client.go │ ├── jikan.go │ ├── links.go │ ├── localTracking.go │ ├── player.go │ ├── provider.go │ ├── provider_allanime.go │ ├── provider_animepahe.go │ ├── rofi.go │ ├── selection_menu.go │ ├── structs.go │ ├── unix_ipc.go │ └── windows_ipc.go ├── package.nix ├── rofi/ │ ├── contextselect.rasi │ ├── selectanime.rasi │ ├── selectanimepreview.rasi │ └── userinput.rasi └── vendor/ ├── github.com/ │ ├── Microsoft/ │ │ └── go-winio/ │ │ ├── .gitattributes │ │ ├── .golangci.yml │ │ ├── CODEOWNERS │ │ ├── LICENSE │ │ ├── README.md │ │ ├── SECURITY.md │ │ ├── backup.go │ │ ├── doc.go │ │ ├── ea.go │ │ ├── file.go │ │ ├── fileinfo.go │ │ ├── hvsock.go │ │ ├── internal/ │ │ │ ├── fs/ │ │ │ │ ├── doc.go │ │ │ │ ├── fs.go │ │ │ │ ├── security.go │ │ │ │ └── zsyscall_windows.go │ │ │ ├── socket/ │ │ │ │ ├── rawaddr.go │ │ │ │ ├── socket.go │ │ │ │ └── zsyscall_windows.go │ │ │ └── stringbuffer/ │ │ │ └── wstring.go │ │ ├── pipe.go │ │ ├── pkg/ │ │ │ └── guid/ │ │ │ ├── guid.go │ │ │ ├── guid_nonwindows.go │ │ │ ├── guid_windows.go │ │ │ └── variant_string.go │ │ ├── privilege.go │ │ ├── reparse.go │ │ ├── sd.go │ │ ├── syscall.go │ │ └── zsyscall_windows.go │ ├── aymanbagabas/ │ │ └── go-osc52/ │ │ └── v2/ │ │ ├── LICENSE │ │ ├── README.md │ │ └── osc52.go │ ├── charmbracelet/ │ │ ├── bubbletea/ │ │ │ ├── .gitattributes │ │ │ ├── .golangci-soft.yml │ │ │ ├── .golangci.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── commands.go │ │ │ ├── exec.go │ │ │ ├── focus.go │ │ │ ├── inputreader_other.go │ │ │ ├── inputreader_windows.go │ │ │ ├── key.go │ │ │ ├── key_other.go │ │ │ ├── key_sequences.go │ │ │ ├── key_windows.go │ │ │ ├── logging.go │ │ │ ├── mouse.go │ │ │ ├── nil_renderer.go │ │ │ ├── options.go │ │ │ ├── renderer.go │ │ │ ├── screen.go │ │ │ ├── signals_unix.go │ │ │ ├── signals_windows.go │ │ │ ├── standard_renderer.go │ │ │ ├── tea.go │ │ │ ├── tea_init.go │ │ │ ├── tty.go │ │ │ ├── tty_unix.go │ │ │ └── tty_windows.go │ │ ├── lipgloss/ │ │ │ ├── .golangci-soft.yml │ │ │ ├── .golangci.yml │ │ │ ├── .goreleaser.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── align.go │ │ │ ├── ansi_unix.go │ │ │ ├── ansi_windows.go │ │ │ ├── borders.go │ │ │ ├── color.go │ │ │ ├── get.go │ │ │ ├── join.go │ │ │ ├── position.go │ │ │ ├── renderer.go │ │ │ ├── runes.go │ │ │ ├── set.go │ │ │ ├── size.go │ │ │ ├── style.go │ │ │ ├── unset.go │ │ │ └── whitespace.go │ │ └── x/ │ │ ├── ansi/ │ │ │ ├── LICENSE │ │ │ ├── ansi.go │ │ │ ├── ascii.go │ │ │ ├── background.go │ │ │ ├── c0.go │ │ │ ├── c1.go │ │ │ ├── charset.go │ │ │ ├── clipboard.go │ │ │ ├── color.go │ │ │ ├── ctrl.go │ │ │ ├── cursor.go │ │ │ ├── cwd.go │ │ │ ├── doc.go │ │ │ ├── focus.go │ │ │ ├── graphics.go │ │ │ ├── hyperlink.go │ │ │ ├── iterm2.go │ │ │ ├── keypad.go │ │ │ ├── kitty/ │ │ │ │ ├── decoder.go │ │ │ │ ├── encoder.go │ │ │ │ ├── graphics.go │ │ │ │ └── options.go │ │ │ ├── kitty.go │ │ │ ├── method.go │ │ │ ├── mode.go │ │ │ ├── modes.go │ │ │ ├── mouse.go │ │ │ ├── notification.go │ │ │ ├── parser/ │ │ │ │ ├── const.go │ │ │ │ ├── seq.go │ │ │ │ └── transition_table.go │ │ │ ├── parser.go │ │ │ ├── parser_decode.go │ │ │ ├── parser_handler.go │ │ │ ├── parser_sync.go │ │ │ ├── passthrough.go │ │ │ ├── paste.go │ │ │ ├── reset.go │ │ │ ├── screen.go │ │ │ ├── sgr.go │ │ │ ├── status.go │ │ │ ├── style.go │ │ │ ├── termcap.go │ │ │ ├── title.go │ │ │ ├── truncate.go │ │ │ ├── util.go │ │ │ ├── width.go │ │ │ ├── winop.go │ │ │ ├── wrap.go │ │ │ └── xterm.go │ │ └── term/ │ │ ├── LICENSE │ │ ├── term.go │ │ ├── term_other.go │ │ ├── term_unix.go │ │ ├── term_unix_bsd.go │ │ ├── term_unix_other.go │ │ ├── term_windows.go │ │ ├── terminal.go │ │ └── util.go │ ├── erikgeiser/ │ │ └── coninput/ │ │ ├── .golangci.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── keycodes.go │ │ ├── mode.go │ │ ├── read.go │ │ └── records.go │ ├── gen2brain/ │ │ └── beeep/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── alert_darwin.go │ │ ├── alert_js.go │ │ ├── alert_unix.go │ │ ├── alert_unsupported.go │ │ ├── alert_windows.go │ │ ├── beeep.go │ │ ├── beep_darwin.go │ │ ├── beep_js.go │ │ ├── beep_unix.go │ │ ├── beep_unsupported.go │ │ ├── beep_windows.go │ │ ├── notify_darwin.go │ │ ├── notify_js.go │ │ ├── notify_unix.go │ │ ├── notify_unix_nodbus.go │ │ ├── notify_unsupported.go │ │ └── notify_windows.go │ ├── go-rod/ │ │ └── rod/ │ │ ├── .eslintrc.yml │ │ ├── .golangci.yml │ │ ├── .prettierrc.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── browser.go │ │ ├── context.go │ │ ├── dev_helpers.go │ │ ├── element.go │ │ ├── error.go │ │ ├── go.work │ │ ├── go.work.sum │ │ ├── hijack.go │ │ ├── input.go │ │ ├── lib/ │ │ │ ├── assets/ │ │ │ │ ├── README.md │ │ │ │ ├── assets.go │ │ │ │ ├── monitor-page.html │ │ │ │ └── monitor.html │ │ │ ├── cdp/ │ │ │ │ ├── README.md │ │ │ │ ├── client.go │ │ │ │ ├── error.go │ │ │ │ ├── format.go │ │ │ │ ├── utils.go │ │ │ │ └── websocket.go │ │ │ ├── defaults/ │ │ │ │ └── defaults.go │ │ │ ├── devices/ │ │ │ │ ├── device.go │ │ │ │ ├── list.go │ │ │ │ └── utils.go │ │ │ ├── input/ │ │ │ │ ├── README.md │ │ │ │ ├── keyboard.go │ │ │ │ ├── keymap.go │ │ │ │ ├── mac_comands.go │ │ │ │ └── mouse.go │ │ │ ├── js/ │ │ │ │ ├── helper.go │ │ │ │ ├── helper.js │ │ │ │ └── js.go │ │ │ ├── launcher/ │ │ │ │ ├── README.md │ │ │ │ ├── browser.go │ │ │ │ ├── error.go │ │ │ │ ├── flags/ │ │ │ │ │ └── flags.go │ │ │ │ ├── launcher.go │ │ │ │ ├── manager.go │ │ │ │ ├── os_unix.go │ │ │ │ ├── os_windows.go │ │ │ │ ├── revision.go │ │ │ │ ├── url_parser.go │ │ │ │ └── utils.go │ │ │ ├── proto/ │ │ │ │ ├── README.md │ │ │ │ ├── a_interface.go │ │ │ │ ├── a_patch.go │ │ │ │ ├── a_utils.go │ │ │ │ ├── accessibility.go │ │ │ │ ├── animation.go │ │ │ │ ├── audits.go │ │ │ │ ├── autofill.go │ │ │ │ ├── background_service.go │ │ │ │ ├── browser.go │ │ │ │ ├── cache_storage.go │ │ │ │ ├── cast.go │ │ │ │ ├── console.go │ │ │ │ ├── css.go │ │ │ │ ├── database.go │ │ │ │ ├── debugger.go │ │ │ │ ├── definitions.go │ │ │ │ ├── device_access.go │ │ │ │ ├── device_orientation.go │ │ │ │ ├── dom.go │ │ │ │ ├── dom_debugger.go │ │ │ │ ├── dom_snapshot.go │ │ │ │ ├── dom_storage.go │ │ │ │ ├── emulation.go │ │ │ │ ├── event_breakpoints.go │ │ │ │ ├── extensions.go │ │ │ │ ├── fed_cm.go │ │ │ │ ├── fetch.go │ │ │ │ ├── headless_experimental.go │ │ │ │ ├── heap_profiler.go │ │ │ │ ├── indexed_db.go │ │ │ │ ├── input.go │ │ │ │ ├── inspector.go │ │ │ │ ├── io.go │ │ │ │ ├── layer_tree.go │ │ │ │ ├── log.go │ │ │ │ ├── media.go │ │ │ │ ├── memory.go │ │ │ │ ├── network.go │ │ │ │ ├── overlay.go │ │ │ │ ├── page.go │ │ │ │ ├── performance.go │ │ │ │ ├── performance_timeline.go │ │ │ │ ├── preload.go │ │ │ │ ├── profiler.go │ │ │ │ ├── pwa.go │ │ │ │ ├── runtime.go │ │ │ │ ├── schema.go │ │ │ │ ├── security.go │ │ │ │ ├── service_worker.go │ │ │ │ ├── storage.go │ │ │ │ ├── system_info.go │ │ │ │ ├── target.go │ │ │ │ ├── tethering.go │ │ │ │ ├── tracing.go │ │ │ │ ├── web_audio.go │ │ │ │ └── web_authn.go │ │ │ └── utils/ │ │ │ ├── imageutil.go │ │ │ ├── sleeper.go │ │ │ └── utils.go │ │ ├── must.go │ │ ├── page.go │ │ ├── page_eval.go │ │ ├── query.go │ │ ├── states.go │ │ └── utils.go │ ├── go-toast/ │ │ └── toast/ │ │ ├── LICENSE │ │ ├── readme.md │ │ └── toast.go │ ├── godbus/ │ │ └── dbus/ │ │ └── v5/ │ │ ├── CONTRIBUTING.md │ │ ├── LICENSE │ │ ├── MAINTAINERS │ │ ├── README.md │ │ ├── auth.go │ │ ├── auth_anonymous.go │ │ ├── auth_external.go │ │ ├── auth_sha1.go │ │ ├── call.go │ │ ├── conn.go │ │ ├── conn_darwin.go │ │ ├── conn_other.go │ │ ├── conn_unix.go │ │ ├── conn_windows.go │ │ ├── dbus.go │ │ ├── decoder.go │ │ ├── default_handler.go │ │ ├── doc.go │ │ ├── encoder.go │ │ ├── escape.go │ │ ├── export.go │ │ ├── homedir.go │ │ ├── match.go │ │ ├── message.go │ │ ├── object.go │ │ ├── sequence.go │ │ ├── sequential_handler.go │ │ ├── server_interfaces.go │ │ ├── sig.go │ │ ├── transport_darwin.go │ │ ├── transport_generic.go │ │ ├── transport_nonce_tcp.go │ │ ├── transport_tcp.go │ │ ├── transport_unix.go │ │ ├── transport_unixcred_dragonfly.go │ │ ├── transport_unixcred_freebsd.go │ │ ├── transport_unixcred_linux.go │ │ ├── transport_unixcred_netbsd.go │ │ ├── transport_unixcred_openbsd.go │ │ ├── transport_zos.go │ │ ├── variant.go │ │ ├── variant_lexer.go │ │ └── variant_parser.go │ ├── lucasb-eyer/ │ │ └── go-colorful/ │ │ ├── CHANGELOG.md │ │ ├── LICENSE │ │ ├── README.md │ │ ├── colorgens.go │ │ ├── colors.go │ │ ├── happy_palettegen.go │ │ ├── hexcolor.go │ │ ├── hsluv-snapshot-rev4.json │ │ ├── hsluv.go │ │ ├── soft_palettegen.go │ │ └── warm_palettegen.go │ ├── mattn/ │ │ ├── go-isatty/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── doc.go │ │ │ ├── go.test.sh │ │ │ ├── isatty_bsd.go │ │ │ ├── isatty_others.go │ │ │ ├── isatty_plan9.go │ │ │ ├── isatty_solaris.go │ │ │ ├── isatty_tcgets.go │ │ │ └── isatty_windows.go │ │ ├── go-localereader/ │ │ │ ├── README.md │ │ │ ├── localereader.go │ │ │ ├── localereader_unix.go │ │ │ └── localereader_windows.go │ │ └── go-runewidth/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── runewidth.go │ │ ├── runewidth_appengine.go │ │ ├── runewidth_js.go │ │ ├── runewidth_posix.go │ │ ├── runewidth_table.go │ │ └── runewidth_windows.go │ ├── muesli/ │ │ ├── ansi/ │ │ │ ├── .golangci.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── ansi.go │ │ │ ├── buffer.go │ │ │ ├── compressor/ │ │ │ │ └── writer.go │ │ │ └── writer.go │ │ ├── cancelreader/ │ │ │ ├── .golangci-soft.yml │ │ │ ├── .golangci.yml │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── cancelreader.go │ │ │ ├── cancelreader_bsd.go │ │ │ ├── cancelreader_default.go │ │ │ ├── cancelreader_linux.go │ │ │ ├── cancelreader_select.go │ │ │ ├── cancelreader_unix.go │ │ │ └── cancelreader_windows.go │ │ └── termenv/ │ │ ├── .golangci-soft.yml │ │ ├── .golangci.yml │ │ ├── LICENSE │ │ ├── README.md │ │ ├── ansi_compat.md │ │ ├── ansicolors.go │ │ ├── color.go │ │ ├── constants_linux.go │ │ ├── constants_solaris.go │ │ ├── constants_unix.go │ │ ├── copy.go │ │ ├── hyperlink.go │ │ ├── notification.go │ │ ├── output.go │ │ ├── profile.go │ │ ├── screen.go │ │ ├── style.go │ │ ├── templatehelper.go │ │ ├── termenv.go │ │ ├── termenv_other.go │ │ ├── termenv_posix.go │ │ ├── termenv_solaris.go │ │ ├── termenv_unix.go │ │ └── termenv_windows.go │ ├── nu7hatch/ │ │ └── gouuid/ │ │ ├── COPYING │ │ ├── README.md │ │ └── uuid.go │ ├── pkg/ │ │ └── browser/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── browser.go │ │ ├── browser_darwin.go │ │ ├── browser_freebsd.go │ │ ├── browser_linux.go │ │ ├── browser_netbsd.go │ │ ├── browser_openbsd.go │ │ ├── browser_unsupported.go │ │ └── browser_windows.go │ ├── rivo/ │ │ └── uniseg/ │ │ ├── LICENSE.txt │ │ ├── README.md │ │ ├── doc.go │ │ ├── eastasianwidth.go │ │ ├── emojipresentation.go │ │ ├── gen_breaktest.go │ │ ├── gen_properties.go │ │ ├── grapheme.go │ │ ├── graphemeproperties.go │ │ ├── graphemerules.go │ │ ├── line.go │ │ ├── lineproperties.go │ │ ├── linerules.go │ │ ├── properties.go │ │ ├── sentence.go │ │ ├── sentenceproperties.go │ │ ├── sentencerules.go │ │ ├── step.go │ │ ├── width.go │ │ ├── word.go │ │ ├── wordproperties.go │ │ └── wordrules.go │ ├── tadvi/ │ │ └── systray/ │ │ ├── AUTHORS │ │ ├── LICENSE │ │ ├── README.md │ │ ├── systray_linux.go │ │ └── systray_windows.go │ ├── tr1xem/ │ │ └── go-discordrpc/ │ │ ├── LICENSE │ │ ├── client/ │ │ │ ├── client.go │ │ │ ├── inputMapper.go │ │ │ └── types.go │ │ └── internal/ │ │ └── ipc/ │ │ ├── ipc.go │ │ ├── ipc_unix.go │ │ └── ipc_windows.go │ └── ysmood/ │ ├── fetchup/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── download.go │ │ ├── events.go │ │ ├── fetchup.go │ │ └── utils.go │ ├── goob/ │ │ ├── .golangci.yml │ │ ├── LICENSE │ │ ├── goob.go │ │ ├── pipe.go │ │ └── readme.md │ ├── got/ │ │ ├── LICENSE │ │ └── lib/ │ │ └── lcs/ │ │ ├── lcs.go │ │ ├── sequence.go │ │ └── utils.go │ ├── gson/ │ │ ├── LICENSE │ │ ├── README.md │ │ ├── read.go │ │ └── write.go │ └── leakless/ │ ├── .golangci.yml │ ├── LICENSE │ ├── bin_amd64_darwin.go │ ├── bin_amd64_linux.go │ ├── bin_amd64_windows.go │ ├── bin_arm64_darwin.go │ ├── bin_arm64_linux.go │ ├── leakless.go │ ├── pkg/ │ │ ├── shared/ │ │ │ ├── message.go │ │ │ └── version.go │ │ └── utils/ │ │ ├── target.go │ │ └── utils.go │ └── readme.md ├── golang.org/ │ └── x/ │ ├── sync/ │ │ ├── LICENSE │ │ ├── PATENTS │ │ └── errgroup/ │ │ ├── errgroup.go │ │ ├── go120.go │ │ └── pre_go120.go │ ├── sys/ │ │ ├── LICENSE │ │ ├── PATENTS │ │ ├── unix/ │ │ │ ├── README.md │ │ │ ├── affinity_linux.go │ │ │ ├── aliases.go │ │ │ ├── asm_aix_ppc64.s │ │ │ ├── asm_bsd_386.s │ │ │ ├── asm_bsd_amd64.s │ │ │ ├── asm_bsd_arm.s │ │ │ ├── asm_bsd_arm64.s │ │ │ ├── asm_bsd_ppc64.s │ │ │ ├── asm_bsd_riscv64.s │ │ │ ├── asm_linux_386.s │ │ │ ├── asm_linux_amd64.s │ │ │ ├── asm_linux_arm.s │ │ │ ├── asm_linux_arm64.s │ │ │ ├── asm_linux_loong64.s │ │ │ ├── asm_linux_mips64x.s │ │ │ ├── asm_linux_mipsx.s │ │ │ ├── asm_linux_ppc64x.s │ │ │ ├── asm_linux_riscv64.s │ │ │ ├── asm_linux_s390x.s │ │ │ ├── asm_openbsd_mips64.s │ │ │ ├── asm_solaris_amd64.s │ │ │ ├── asm_zos_s390x.s │ │ │ ├── auxv.go │ │ │ ├── auxv_unsupported.go │ │ │ ├── bluetooth_linux.go │ │ │ ├── bpxsvc_zos.go │ │ │ ├── bpxsvc_zos.s │ │ │ ├── cap_freebsd.go │ │ │ ├── constants.go │ │ │ ├── dev_aix_ppc.go │ │ │ ├── dev_aix_ppc64.go │ │ │ ├── dev_darwin.go │ │ │ ├── dev_dragonfly.go │ │ │ ├── dev_freebsd.go │ │ │ ├── dev_linux.go │ │ │ ├── dev_netbsd.go │ │ │ ├── dev_openbsd.go │ │ │ ├── dev_zos.go │ │ │ ├── dirent.go │ │ │ ├── endian_big.go │ │ │ ├── endian_little.go │ │ │ ├── env_unix.go │ │ │ ├── fcntl.go │ │ │ ├── fcntl_darwin.go │ │ │ ├── fcntl_linux_32bit.go │ │ │ ├── fdset.go │ │ │ ├── gccgo.go │ │ │ ├── gccgo_c.c │ │ │ ├── gccgo_linux_amd64.go │ │ │ ├── ifreq_linux.go │ │ │ ├── ioctl_linux.go │ │ │ ├── ioctl_signed.go │ │ │ ├── ioctl_unsigned.go │ │ │ ├── ioctl_zos.go │ │ │ ├── mkall.sh │ │ │ ├── mkerrors.sh │ │ │ ├── mmap_nomremap.go │ │ │ ├── mremap.go │ │ │ ├── pagesize_unix.go │ │ │ ├── pledge_openbsd.go │ │ │ ├── ptrace_darwin.go │ │ │ ├── ptrace_ios.go │ │ │ ├── race.go │ │ │ ├── race0.go │ │ │ ├── readdirent_getdents.go │ │ │ ├── readdirent_getdirentries.go │ │ │ ├── sockcmsg_dragonfly.go │ │ │ ├── sockcmsg_linux.go │ │ │ ├── sockcmsg_unix.go │ │ │ ├── sockcmsg_unix_other.go │ │ │ ├── sockcmsg_zos.go │ │ │ ├── symaddr_zos_s390x.s │ │ │ ├── syscall.go │ │ │ ├── syscall_aix.go │ │ │ ├── syscall_aix_ppc.go │ │ │ ├── syscall_aix_ppc64.go │ │ │ ├── syscall_bsd.go │ │ │ ├── syscall_darwin.go │ │ │ ├── syscall_darwin_amd64.go │ │ │ ├── syscall_darwin_arm64.go │ │ │ ├── syscall_darwin_libSystem.go │ │ │ ├── syscall_dragonfly.go │ │ │ ├── syscall_dragonfly_amd64.go │ │ │ ├── syscall_freebsd.go │ │ │ ├── syscall_freebsd_386.go │ │ │ ├── syscall_freebsd_amd64.go │ │ │ ├── syscall_freebsd_arm.go │ │ │ ├── syscall_freebsd_arm64.go │ │ │ ├── syscall_freebsd_riscv64.go │ │ │ ├── syscall_hurd.go │ │ │ ├── syscall_hurd_386.go │ │ │ ├── syscall_illumos.go │ │ │ ├── syscall_linux.go │ │ │ ├── syscall_linux_386.go │ │ │ ├── syscall_linux_alarm.go │ │ │ ├── syscall_linux_amd64.go │ │ │ ├── syscall_linux_amd64_gc.go │ │ │ ├── syscall_linux_arm.go │ │ │ ├── syscall_linux_arm64.go │ │ │ ├── syscall_linux_gc.go │ │ │ ├── syscall_linux_gc_386.go │ │ │ ├── syscall_linux_gc_arm.go │ │ │ ├── syscall_linux_gccgo_386.go │ │ │ ├── syscall_linux_gccgo_arm.go │ │ │ ├── syscall_linux_loong64.go │ │ │ ├── syscall_linux_mips64x.go │ │ │ ├── syscall_linux_mipsx.go │ │ │ ├── syscall_linux_ppc.go │ │ │ ├── syscall_linux_ppc64x.go │ │ │ ├── syscall_linux_riscv64.go │ │ │ ├── syscall_linux_s390x.go │ │ │ ├── syscall_linux_sparc64.go │ │ │ ├── syscall_netbsd.go │ │ │ ├── syscall_netbsd_386.go │ │ │ ├── syscall_netbsd_amd64.go │ │ │ ├── syscall_netbsd_arm.go │ │ │ ├── syscall_netbsd_arm64.go │ │ │ ├── syscall_openbsd.go │ │ │ ├── syscall_openbsd_386.go │ │ │ ├── syscall_openbsd_amd64.go │ │ │ ├── syscall_openbsd_arm.go │ │ │ ├── syscall_openbsd_arm64.go │ │ │ ├── syscall_openbsd_libc.go │ │ │ ├── syscall_openbsd_mips64.go │ │ │ ├── syscall_openbsd_ppc64.go │ │ │ ├── syscall_openbsd_riscv64.go │ │ │ ├── syscall_solaris.go │ │ │ ├── syscall_solaris_amd64.go │ │ │ ├── syscall_unix.go │ │ │ ├── syscall_unix_gc.go │ │ │ ├── syscall_unix_gc_ppc64x.go │ │ │ ├── syscall_zos_s390x.go │ │ │ ├── sysvshm_linux.go │ │ │ ├── sysvshm_unix.go │ │ │ ├── sysvshm_unix_other.go │ │ │ ├── timestruct.go │ │ │ ├── unveil_openbsd.go │ │ │ ├── vgetrandom_linux.go │ │ │ ├── vgetrandom_unsupported.go │ │ │ ├── xattr_bsd.go │ │ │ ├── zerrors_aix_ppc.go │ │ │ ├── zerrors_aix_ppc64.go │ │ │ ├── zerrors_darwin_amd64.go │ │ │ ├── zerrors_darwin_arm64.go │ │ │ ├── zerrors_dragonfly_amd64.go │ │ │ ├── zerrors_freebsd_386.go │ │ │ ├── zerrors_freebsd_amd64.go │ │ │ ├── zerrors_freebsd_arm.go │ │ │ ├── zerrors_freebsd_arm64.go │ │ │ ├── zerrors_freebsd_riscv64.go │ │ │ ├── zerrors_linux.go │ │ │ ├── zerrors_linux_386.go │ │ │ ├── zerrors_linux_amd64.go │ │ │ ├── zerrors_linux_arm.go │ │ │ ├── zerrors_linux_arm64.go │ │ │ ├── zerrors_linux_loong64.go │ │ │ ├── zerrors_linux_mips.go │ │ │ ├── zerrors_linux_mips64.go │ │ │ ├── zerrors_linux_mips64le.go │ │ │ ├── zerrors_linux_mipsle.go │ │ │ ├── zerrors_linux_ppc.go │ │ │ ├── zerrors_linux_ppc64.go │ │ │ ├── zerrors_linux_ppc64le.go │ │ │ ├── zerrors_linux_riscv64.go │ │ │ ├── zerrors_linux_s390x.go │ │ │ ├── zerrors_linux_sparc64.go │ │ │ ├── zerrors_netbsd_386.go │ │ │ ├── zerrors_netbsd_amd64.go │ │ │ ├── zerrors_netbsd_arm.go │ │ │ ├── zerrors_netbsd_arm64.go │ │ │ ├── zerrors_openbsd_386.go │ │ │ ├── zerrors_openbsd_amd64.go │ │ │ ├── zerrors_openbsd_arm.go │ │ │ ├── zerrors_openbsd_arm64.go │ │ │ ├── zerrors_openbsd_mips64.go │ │ │ ├── zerrors_openbsd_ppc64.go │ │ │ ├── zerrors_openbsd_riscv64.go │ │ │ ├── zerrors_solaris_amd64.go │ │ │ ├── zerrors_zos_s390x.go │ │ │ ├── zptrace_armnn_linux.go │ │ │ ├── zptrace_linux_arm64.go │ │ │ ├── zptrace_mipsnn_linux.go │ │ │ ├── zptrace_mipsnnle_linux.go │ │ │ ├── zptrace_x86_linux.go │ │ │ ├── zsymaddr_zos_s390x.s │ │ │ ├── zsyscall_aix_ppc.go │ │ │ ├── zsyscall_aix_ppc64.go │ │ │ ├── zsyscall_aix_ppc64_gc.go │ │ │ ├── zsyscall_aix_ppc64_gccgo.go │ │ │ ├── zsyscall_darwin_amd64.go │ │ │ ├── zsyscall_darwin_amd64.s │ │ │ ├── zsyscall_darwin_arm64.go │ │ │ ├── zsyscall_darwin_arm64.s │ │ │ ├── zsyscall_dragonfly_amd64.go │ │ │ ├── zsyscall_freebsd_386.go │ │ │ ├── zsyscall_freebsd_amd64.go │ │ │ ├── zsyscall_freebsd_arm.go │ │ │ ├── zsyscall_freebsd_arm64.go │ │ │ ├── zsyscall_freebsd_riscv64.go │ │ │ ├── zsyscall_illumos_amd64.go │ │ │ ├── zsyscall_linux.go │ │ │ ├── zsyscall_linux_386.go │ │ │ ├── zsyscall_linux_amd64.go │ │ │ ├── zsyscall_linux_arm.go │ │ │ ├── zsyscall_linux_arm64.go │ │ │ ├── zsyscall_linux_loong64.go │ │ │ ├── zsyscall_linux_mips.go │ │ │ ├── zsyscall_linux_mips64.go │ │ │ ├── zsyscall_linux_mips64le.go │ │ │ ├── zsyscall_linux_mipsle.go │ │ │ ├── zsyscall_linux_ppc.go │ │ │ ├── zsyscall_linux_ppc64.go │ │ │ ├── zsyscall_linux_ppc64le.go │ │ │ ├── zsyscall_linux_riscv64.go │ │ │ ├── zsyscall_linux_s390x.go │ │ │ ├── zsyscall_linux_sparc64.go │ │ │ ├── zsyscall_netbsd_386.go │ │ │ ├── zsyscall_netbsd_amd64.go │ │ │ ├── zsyscall_netbsd_arm.go │ │ │ ├── zsyscall_netbsd_arm64.go │ │ │ ├── zsyscall_openbsd_386.go │ │ │ ├── zsyscall_openbsd_386.s │ │ │ ├── zsyscall_openbsd_amd64.go │ │ │ ├── zsyscall_openbsd_amd64.s │ │ │ ├── zsyscall_openbsd_arm.go │ │ │ ├── zsyscall_openbsd_arm.s │ │ │ ├── zsyscall_openbsd_arm64.go │ │ │ ├── zsyscall_openbsd_arm64.s │ │ │ ├── zsyscall_openbsd_mips64.go │ │ │ ├── zsyscall_openbsd_mips64.s │ │ │ ├── zsyscall_openbsd_ppc64.go │ │ │ ├── zsyscall_openbsd_ppc64.s │ │ │ ├── zsyscall_openbsd_riscv64.go │ │ │ ├── zsyscall_openbsd_riscv64.s │ │ │ ├── zsyscall_solaris_amd64.go │ │ │ ├── zsyscall_zos_s390x.go │ │ │ ├── zsysctl_openbsd_386.go │ │ │ ├── zsysctl_openbsd_amd64.go │ │ │ ├── zsysctl_openbsd_arm.go │ │ │ ├── zsysctl_openbsd_arm64.go │ │ │ ├── zsysctl_openbsd_mips64.go │ │ │ ├── zsysctl_openbsd_ppc64.go │ │ │ ├── zsysctl_openbsd_riscv64.go │ │ │ ├── zsysnum_darwin_amd64.go │ │ │ ├── zsysnum_darwin_arm64.go │ │ │ ├── zsysnum_dragonfly_amd64.go │ │ │ ├── zsysnum_freebsd_386.go │ │ │ ├── zsysnum_freebsd_amd64.go │ │ │ ├── zsysnum_freebsd_arm.go │ │ │ ├── zsysnum_freebsd_arm64.go │ │ │ ├── zsysnum_freebsd_riscv64.go │ │ │ ├── zsysnum_linux_386.go │ │ │ ├── zsysnum_linux_amd64.go │ │ │ ├── zsysnum_linux_arm.go │ │ │ ├── zsysnum_linux_arm64.go │ │ │ ├── zsysnum_linux_loong64.go │ │ │ ├── zsysnum_linux_mips.go │ │ │ ├── zsysnum_linux_mips64.go │ │ │ ├── zsysnum_linux_mips64le.go │ │ │ ├── zsysnum_linux_mipsle.go │ │ │ ├── zsysnum_linux_ppc.go │ │ │ ├── zsysnum_linux_ppc64.go │ │ │ ├── zsysnum_linux_ppc64le.go │ │ │ ├── zsysnum_linux_riscv64.go │ │ │ ├── zsysnum_linux_s390x.go │ │ │ ├── zsysnum_linux_sparc64.go │ │ │ ├── zsysnum_netbsd_386.go │ │ │ ├── zsysnum_netbsd_amd64.go │ │ │ ├── zsysnum_netbsd_arm.go │ │ │ ├── zsysnum_netbsd_arm64.go │ │ │ ├── zsysnum_openbsd_386.go │ │ │ ├── zsysnum_openbsd_amd64.go │ │ │ ├── zsysnum_openbsd_arm.go │ │ │ ├── zsysnum_openbsd_arm64.go │ │ │ ├── zsysnum_openbsd_mips64.go │ │ │ ├── zsysnum_openbsd_ppc64.go │ │ │ ├── zsysnum_openbsd_riscv64.go │ │ │ ├── zsysnum_zos_s390x.go │ │ │ ├── ztypes_aix_ppc.go │ │ │ ├── ztypes_aix_ppc64.go │ │ │ ├── ztypes_darwin_amd64.go │ │ │ ├── ztypes_darwin_arm64.go │ │ │ ├── ztypes_dragonfly_amd64.go │ │ │ ├── ztypes_freebsd_386.go │ │ │ ├── ztypes_freebsd_amd64.go │ │ │ ├── ztypes_freebsd_arm.go │ │ │ ├── ztypes_freebsd_arm64.go │ │ │ ├── ztypes_freebsd_riscv64.go │ │ │ ├── ztypes_linux.go │ │ │ ├── ztypes_linux_386.go │ │ │ ├── ztypes_linux_amd64.go │ │ │ ├── ztypes_linux_arm.go │ │ │ ├── ztypes_linux_arm64.go │ │ │ ├── ztypes_linux_loong64.go │ │ │ ├── ztypes_linux_mips.go │ │ │ ├── ztypes_linux_mips64.go │ │ │ ├── ztypes_linux_mips64le.go │ │ │ ├── ztypes_linux_mipsle.go │ │ │ ├── ztypes_linux_ppc.go │ │ │ ├── ztypes_linux_ppc64.go │ │ │ ├── ztypes_linux_ppc64le.go │ │ │ ├── ztypes_linux_riscv64.go │ │ │ ├── ztypes_linux_s390x.go │ │ │ ├── ztypes_linux_sparc64.go │ │ │ ├── ztypes_netbsd_386.go │ │ │ ├── ztypes_netbsd_amd64.go │ │ │ ├── ztypes_netbsd_arm.go │ │ │ ├── ztypes_netbsd_arm64.go │ │ │ ├── ztypes_openbsd_386.go │ │ │ ├── ztypes_openbsd_amd64.go │ │ │ ├── ztypes_openbsd_arm.go │ │ │ ├── ztypes_openbsd_arm64.go │ │ │ ├── ztypes_openbsd_mips64.go │ │ │ ├── ztypes_openbsd_ppc64.go │ │ │ ├── ztypes_openbsd_riscv64.go │ │ │ ├── ztypes_solaris_amd64.go │ │ │ └── ztypes_zos_s390x.go │ │ └── windows/ │ │ ├── aliases.go │ │ ├── dll_windows.go │ │ ├── env_windows.go │ │ ├── eventlog.go │ │ ├── exec_windows.go │ │ ├── memory_windows.go │ │ ├── mkerrors.bash │ │ ├── mkknownfolderids.bash │ │ ├── mksyscall.go │ │ ├── race.go │ │ ├── race0.go │ │ ├── registry/ │ │ │ ├── key.go │ │ │ ├── mksyscall.go │ │ │ ├── syscall.go │ │ │ ├── value.go │ │ │ └── zsyscall_windows.go │ │ ├── security_windows.go │ │ ├── service.go │ │ ├── setupapi_windows.go │ │ ├── str.go │ │ ├── syscall.go │ │ ├── syscall_windows.go │ │ ├── types_windows.go │ │ ├── types_windows_386.go │ │ ├── types_windows_amd64.go │ │ ├── types_windows_arm.go │ │ ├── types_windows_arm64.go │ │ ├── zerrors_windows.go │ │ ├── zknownfolderids_windows.go │ │ └── zsyscall_windows.go │ └── text/ │ ├── LICENSE │ ├── PATENTS │ └── transform/ │ └── transform.go ├── gopkg.in/ │ └── natefinch/ │ └── npipe.v2/ │ ├── LICENSE.txt │ ├── README.md │ ├── doc.go │ ├── npipe_windows.go │ ├── znpipe_windows_386.go │ └── znpipe_windows_amd64.go └── modules.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ Build/mpv.exe filter=lfs diff=lfs merge=lfs -text ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: bug, help wanted assignees: Wraient --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Environment (please complete the following information):** - Distro: [e.g. Fedora, Arch] **Debug logs** Debug logs are located at ~/.local/share/curd/debug.log (for Linux) or C:\.local\share\curd\debug.log (for windows) **Optional context** Add any other context about the problem here. ================================================ FILE: .github/workflows/ci-cd.yml ================================================ name: CI/CD Pipeline on: push: branches: - main # Trigger on pushes to the main branch pull_request: branches: - main # Trigger on pull requests targeting the main branch workflow_dispatch: inputs: pr_number: description: 'Pull Request Number' required: false type: string jobs: build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 # Fetch all history and tags ref: ${{ github.event.pull_request.head.sha || (github.event.inputs.pr_number && format('refs/pull/{0}/head', github.event.inputs.pr_number)) || github.sha }} - name: Set up Go uses: actions/setup-go@v4 with: go-version: '1.21' - name: Install Dependencies run: go mod tidy - name: Build Linux binary run: | ./Build/buildlinux mkdir -p release/linux mv curd-linux-x86_64 release/linux/ mv curd-linux-arm64 release/linux/ env: GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_REF: ${{ github.ref }} GITHUB_EVENT_HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} COMPRESS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, 'release:') }} - name: Build macOS x86_64 binary run: | ./Build/buildmac-x86_64 - name: Build macOS ARM64 binary run: | ./Build/buildmac-arm64 - name: Install LLVM run: | sudo apt-get update && sudo apt-get install -y llvm # Find the llvm-lipo command LIPO_CMD=$(find /usr/bin -name "llvm-lipo*" | head -n 1) echo "Found lipo command: $LIPO_CMD" echo "LIPO_CMD=$LIPO_CMD" >> $GITHUB_ENV - name: Create Universal macOS binary run: | ${{ env.LIPO_CMD }} -create "curd-macos-x86_64" "curd-macos-arm64" -output "curd-macos-universal" mkdir -p release/macos mv curd-macos-x86_64 release/macos mv curd-macos-arm64 release/macos mv curd-macos-universal release/macos - name: Build Windows binary (cross-compile) run: | ./Build/buildwindows mkdir -p release/windows mv curd-windows-x86_64.exe release/windows/curd-windows-x86_64.exe env: GITHUB_EVENT_NAME: ${{ github.event_name }} GITHUB_REF: ${{ github.ref }} GITHUB_EVENT_HEAD_COMMIT_MESSAGE: ${{ github.event.head_commit.message }} COMPRESS: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && contains(github.event.head_commit.message, 'release:') }} - name: Upload Artifacts uses: actions/upload-artifact@v4 with: name: curd-binaries path: | release/linux/curd-linux-x86_64 release/linux/curd-linux-arm64 release/macos/curd-macos-x86_64 release/macos/curd-macos-arm64 release/macos/curd-macos-universal release/windows/curd-windows-x86_64.exe retention-days: 1 if-no-files-found: error release: runs-on: windows-latest needs: build permissions: contents: write actions: read if: | github.ref == 'refs/heads/main' && github.event_name == 'push' && (contains(github.event.head_commit.message, 'release:')) steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Checkout MPV binaries uses: actions/checkout@v4 with: ref: mpv-binaries path: mpv-temp sparse-checkout: | Build/mpv.exe.gz Build/mpv.zip - name: Setup MPV binary run: | New-Item -ItemType Directory -Force -Path "Build\mpv" if (Test-Path "mpv-temp/Build/mpv.exe.gz") { Copy-Item "mpv-temp/Build/mpv.exe.gz" "Build\mpv\mpv.exe.gz" 7z x "Build\mpv\mpv.exe.gz" -o"Build\mpv" Remove-Item "Build\mpv\mpv.exe.gz" } else { Write-Host "Warning: MPV binary not found in mpv-binaries branch, downloading from URL..." Invoke-WebRequest -Uri "https://raw.githubusercontent.com/${{ github.repository }}/main/Build/mpv.exe.gz" -OutFile "Build\mpv\mpv.exe.gz" 7z x "Build\mpv\mpv.exe.gz" -o"Build\mpv" Remove-Item "Build\mpv\mpv.exe.gz" } Remove-Item -Recurse -Force "mpv-temp" -ErrorAction SilentlyContinue shell: pwsh - name: Download Artifacts uses: actions/download-artifact@v4 with: name: curd-binaries path: Build - name: Organize Release Files run: | $version = Get-Content VERSION.txt echo "CURD_VERSION=$version" >> $env:GITHUB_ENV $release_dir = "releases/curd-$version" # Create directory structure New-Item -ItemType Directory -Force -Path "$release_dir/windows" New-Item -ItemType Directory -Force -Path "$release_dir/macos" New-Item -ItemType Directory -Force -Path "$release_dir/linux" # List contents of Build directory to debug Write-Host "Contents of Build directory:" Get-ChildItem -Path "Build" -Recurse # Move files to their respective directories Move-Item "Build/windows/curd-windows-x86_64.exe" "$release_dir/windows/" Move-Item "Build/macos/curd-macos-universal" "$release_dir/macos/" Move-Item "Build/macos/curd-macos-x86_64" "$release_dir/macos/" Move-Item "Build/macos/curd-macos-arm64" "$release_dir/macos/" Move-Item "Build/linux/curd-linux-x86_64" "$release_dir/linux/" Move-Item "Build/linux/curd-linux-arm64" "$release_dir/linux/" Copy-Item "Build/mpv/mpv.exe" "$release_dir/windows/" shell: pwsh - name: Update Inno Setup Script with New Version run: | $iss_path = "Build/curd-windows-build.iss" $content = Get-Content $iss_path $content = $content -replace '^AppVersion=.*', "AppVersion=$env:CURD_VERSION" Set-Content $iss_path $content shell: pwsh - name: Create Windows Installer run: | & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "Build\curd-windows-build.iss" shell: pwsh - name: Build direct commit release notes run: | $ErrorActionPreference = 'Stop' $repo = "${{ github.repository }}" $token = "${{ secrets.ACTIONS_PAT }}" $headers = @{ Authorization = "Bearer $token" Accept = "application/vnd.github+json" "X-GitHub-Api-Version" = "2022-11-28" } $currentTag = "v$env:CURD_VERSION" $tags = @(git tag --sort=-version:refname "v*") $previousTag = $null foreach ($tag in $tags) { if ($tag -ne $currentTag) { $previousTag = $tag break } } if ($previousTag) { $range = "$previousTag..HEAD" } else { $rootCommit = (git rev-list --max-parents=0 HEAD | Select-Object -First 1) if ([string]::IsNullOrWhiteSpace($rootCommit)) { $range = "HEAD" } else { $range = "$rootCommit..HEAD" } } $rawCommits = @(git log --no-merges --pretty=format:'%H%x09%s%x09%an' $range) if ($rawCommits.Count -eq 1 -and [string]::IsNullOrWhiteSpace($rawCommits[0])) { $rawCommits = @() } $directCommitLines = @() $contributorSet = @{} foreach ($entry in $rawCommits) { $parts = $entry -split "`t", 3 if ($parts.Count -lt 3) { continue } $sha = $parts[0].Trim() $subject = $parts[1].Trim() $authorName = $parts[2].Trim() if ([string]::IsNullOrWhiteSpace($sha) -or [string]::IsNullOrWhiteSpace($subject)) { continue } if ($subject -match '^\s*release:') { continue } $pullsUri = "https://api.github.com/repos/$repo/commits/$sha/pulls" $pulls = @() try { $pulls = @(Invoke-RestMethod -Uri $pullsUri -Headers $headers -Method Get) } catch { $pulls = @() } # Skip commits that are already represented by pull requests in generated notes. if ($pulls.Count -gt 0) { continue } $commitUri = "https://api.github.com/repos/$repo/commits/$sha" $login = $null try { $commitData = Invoke-RestMethod -Uri $commitUri -Headers $headers -Method Get if ($null -ne $commitData.author -and $null -ne $commitData.author.login) { $login = $commitData.author.login } } catch { $login = $null } $shortSha = $sha.Substring(0, 7) if (-not [string]::IsNullOrWhiteSpace($login)) { $authorDisplay = "[@$login](https://github.com/$login)" } else { $authorDisplay = $authorName } $contributorSet[$authorDisplay] = $true $directCommitLines += "- $subject ([$shortSha](https://github.com/$repo/commit/$sha)) by $authorDisplay" } $notesPath = "Build/release-notes-extra.md" $body = @() $body += "## Direct Commits" if ($directCommitLines.Count -eq 0) { $body += "- No direct commits in this release range." } else { $body += $directCommitLines } $body += "" $body += "## Direct Commit Contributors" $contributors = @($contributorSet.Keys | Sort-Object) if ($contributors.Count -eq 0) { $body += "- None" } else { foreach ($contributor in $contributors) { $body += "- $contributor" } } if ($previousTag) { $body += "" $body += "_Range: $previousTag..HEAD_" } $body | Set-Content -Path $notesPath -Encoding utf8 shell: pwsh - name: Create Release id: create_release uses: softprops/action-gh-release@v1 with: tag_name: "v${{ env.CURD_VERSION }}" name: "Curd v${{ env.CURD_VERSION }}" draft: false prerelease: false generate_release_notes: true body_path: Build/release-notes-extra.md files: | releases/curd-${{ env.CURD_VERSION }}/linux/curd-linux-x86_64 releases/curd-${{ env.CURD_VERSION }}/linux/curd-linux-arm64 releases/curd-${{ env.CURD_VERSION }}/macos/curd-macos-x86_64 releases/curd-${{ env.CURD_VERSION }}/macos/curd-macos-arm64 releases/curd-${{ env.CURD_VERSION }}/macos/curd-macos-universal releases/curd-${{ env.CURD_VERSION }}/windows/curd-windows-x86_64.exe Build/Output/curd-windows-installer.exe env: GITHUB_TOKEN: ${{ secrets.ACTIONS_PAT }} ================================================ FILE: .github/workflows/update-aur.yml ================================================ name: Update AUR Package on: release: types: [published] workflow_dispatch: permissions: contents: read jobs: update-aur: runs-on: ubuntu-24.04 if: github.repository == 'Wraient/curd' steps: - name: Install dependencies run: | sudo apt-get update sudo apt-get install -y curl git jq openssh-client - name: Download release asset and compute SHA256 id: release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail if [ "${{ github.event_name }}" = "release" ]; then TAG_NAME="${{ github.event.release.tag_name }}" RELEASE_API_URL="https://api.github.com/repos/${{ github.repository }}/releases/tags/${TAG_NAME}" else RELEASE_API_URL="https://api.github.com/repos/${{ github.repository }}/releases/latest" fi RELEASE_JSON="$(mktemp)" curl -fsSL \ -H "Authorization: Bearer ${GH_TOKEN}" \ -H "Accept: application/vnd.github+json" \ "$RELEASE_API_URL" \ -o "$RELEASE_JSON" TAG_NAME="$(jq -r '.tag_name // empty' "$RELEASE_JSON")" ASSET_URL="$(jq -r '.assets[] | select(.name=="curd-linux-x86_64") | .browser_download_url' "$RELEASE_JSON" | head -n1)" if [ -z "$TAG_NAME" ] || [ "$TAG_NAME" = "null" ]; then echo "Could not resolve release tag from $RELEASE_API_URL" rm -f "$RELEASE_JSON" exit 1 fi if [ -z "$ASSET_URL" ] || [ "$ASSET_URL" = "null" ]; then echo "Release ${TAG_NAME} does not contain asset: curd-linux-x86_64" rm -f "$RELEASE_JSON" exit 1 fi VERSION="${TAG_NAME#v}" TMP_ASSET="$(mktemp)" curl -fL --retry 3 --retry-all-errors --connect-timeout 20 "$ASSET_URL" -o "$TMP_ASSET" SHA256="$(sha256sum "$TMP_ASSET" | awk '{print $1}')" rm -f "$RELEASE_JSON" rm -f "$TMP_ASSET" echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "tag_name=$TAG_NAME" >> "$GITHUB_OUTPUT" echo "sha256=$SHA256" >> "$GITHUB_OUTPUT" - name: Configure SSH for AUR env: AUR_SSH_PRIVATE_KEY: ${{ secrets.AUR_SSH_PRIVATE_KEY }} run: | set -euo pipefail if [ -z "${AUR_SSH_PRIVATE_KEY:-}" ]; then echo "Missing required secret: AUR_SSH_PRIVATE_KEY" exit 1 fi install -m 700 -d ~/.ssh umask 077 KEY_FILE=~/.ssh/curd_aur_deploy RAW_KEY="$(printf '%s' "$AUR_SSH_PRIVATE_KEY" | tr -d '\r')" if printf '%s' "$RAW_KEY" | grep -q 'BEGIN [A-Z0-9 ]*PRIVATE KEY'; then if printf '%s' "$RAW_KEY" | grep -q '\\n'; then printf '%b' "$RAW_KEY" > "$KEY_FILE" else printf '%s\n' "$RAW_KEY" > "$KEY_FILE" fi else if ! printf '%s' "$RAW_KEY" | base64 -d > "$KEY_FILE" 2>/dev/null; then echo "AUR_SSH_PRIVATE_KEY must be a private key (raw, escaped with \\n, or base64-encoded)" exit 1 fi fi tr -d '\r' < "$KEY_FILE" > "${KEY_FILE}.tmp" mv "${KEY_FILE}.tmp" "$KEY_FILE" if [ ! -s "$KEY_FILE" ]; then echo "SSH key file is empty after writing secret" exit 1 fi KEY_HEADER="$(head -n1 "$KEY_FILE" || true)" case "$KEY_HEADER" in "-----BEGIN OPENSSH PRIVATE KEY-----"|"-----BEGIN RSA PRIVATE KEY-----"|"-----BEGIN EC PRIVATE KEY-----"|"-----BEGIN DSA PRIVATE KEY-----"|"-----BEGIN PRIVATE KEY-----") ;; *) echo "AUR_SSH_PRIVATE_KEY has an invalid private key header: $KEY_HEADER" exit 1 ;; esac if ! ssh-keygen -y -f "$KEY_FILE" > ~/.ssh/curd_aur_deploy.pub 2>/dev/null; then echo "AUR_SSH_PRIVATE_KEY is not a valid, unencrypted SSH private key" exit 1 fi chmod 600 "$KEY_FILE" chmod 644 ~/.ssh/curd_aur_deploy.pub ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts echo "Validated deploy key format. Add the matching public key to the AUR account SSH keys." - name: Clone AUR repository env: GIT_SSH_COMMAND: ssh -i ~/.ssh/curd_aur_deploy -o IdentitiesOnly=yes run: | set -euo pipefail git clone --depth 1 ssh://aur@aur.archlinux.org/curd.git aur-curd - name: Update AUR package files working-directory: aur-curd run: | set -euo pipefail { printf '%s\n' '# Maintainer: Wraient ' printf '%s\n' "pkgname='curd'" printf '%s\n' "pkgver=${{ steps.release.outputs.version }}" printf '%s\n' 'pkgrel=1' printf '%s\n' 'pkgdesc="Watch anime in CLI with AniList Tracking, Discord RPC, Intro/Outro/Filler/Recap Skipping, etc."' printf '%s\n' "arch=('x86_64')" printf '%s\n' 'url="https://github.com/Wraient/curd"' printf '%s\n' "license=('GPL')" printf '%s\n' "depends=('mpv' 'rofi' 'ueberzugpp')" printf '%s\n' "provides=('curd')" printf '%s\n' "conflicts=('curd')" printf '%s\n' 'source=("https://github.com/Wraient/curd/releases/download/v${pkgver}/curd-linux-x86_64")' printf '%s\n' "sha256sums=('${{ steps.release.outputs.sha256 }}')" printf '\n' printf '%s\n' 'package() {' printf '%s\n' ' install -Dm755 "$srcdir/curd-linux-x86_64" "$pkgdir/usr/bin/curd"' printf '%s\n' '}' } > PKGBUILD # Fail fast if checksum/source/install-target drift. grep -q '^source=(' PKGBUILD grep -q '^sha256sums=(' PKGBUILD grep -q 'curd-linux-x86_64' PKGBUILD grep -q 'install -Dm755 "\$srcdir/curd-linux-x86_64" "\$pkgdir/usr/bin/curd"' PKGBUILD bash -n PKGBUILD set +u # shellcheck disable=SC1091 source PKGBUILD set -u pkgbase_value="${pkgbase:-}" if [ -z "${pkgbase_value}" ]; then pkgbase_value="${pkgname[0]:-${pkgname:-}}" fi { printf 'pkgbase = %s\n' "$pkgbase_value" [ -n "${pkgdesc:-}" ] && printf '\tpkgdesc = %s\n' "$pkgdesc" [ -n "${pkgver:-}" ] && printf '\tpkgver = %s\n' "$pkgver" [ -n "${pkgrel:-}" ] && printf '\tpkgrel = %s\n' "$pkgrel" [ -n "${epoch:-}" ] && printf '\tepoch = %s\n' "$epoch" [ -n "${url:-}" ] && printf '\turl = %s\n' "$url" for item in "${arch[@]-}"; do [ -n "$item" ] && printf '\tarch = %s\n' "$item"; done for item in "${license[@]-}"; do [ -n "$item" ] && printf '\tlicense = %s\n' "$item"; done for item in "${makedepends[@]-}"; do [ -n "$item" ] && printf '\tmakedepends = %s\n' "$item"; done for item in "${depends[@]-}"; do [ -n "$item" ] && printf '\tdepends = %s\n' "$item"; done for item in "${checkdepends[@]-}"; do [ -n "$item" ] && printf '\tcheckdepends = %s\n' "$item"; done for item in "${optdepends[@]-}"; do [ -n "$item" ] && printf '\toptdepends = %s\n' "$item"; done for item in "${provides[@]-}"; do [ -n "$item" ] && printf '\tprovides = %s\n' "$item"; done for item in "${conflicts[@]-}"; do [ -n "$item" ] && printf '\tconflicts = %s\n' "$item"; done for item in "${replaces[@]-}"; do [ -n "$item" ] && printf '\treplaces = %s\n' "$item"; done for item in "${source[@]-}"; do [ -n "$item" ] && printf '\tsource = %s\n' "$item"; done for item in "${md5sums[@]-}"; do [ -n "$item" ] && printf '\tmd5sums = %s\n' "$item"; done for item in "${sha1sums[@]-}"; do [ -n "$item" ] && printf '\tsha1sums = %s\n' "$item"; done for item in "${sha224sums[@]-}"; do [ -n "$item" ] && printf '\tsha224sums = %s\n' "$item"; done for item in "${sha256sums[@]-}"; do [ -n "$item" ] && printf '\tsha256sums = %s\n' "$item"; done for item in "${sha384sums[@]-}"; do [ -n "$item" ] && printf '\tsha384sums = %s\n' "$item"; done for item in "${sha512sums[@]-}"; do [ -n "$item" ] && printf '\tsha512sums = %s\n' "$item"; done for item in "${b2sums[@]-}"; do [ -n "$item" ] && printf '\tb2sums = %s\n' "$item"; done printf '\n' for pkg in "${pkgname[@]-}"; do [ -n "$pkg" ] && printf 'pkgname = %s\n' "$pkg"; done } > .SRCINFO grep -Fq "pkgver = ${{ steps.release.outputs.version }}" .SRCINFO grep -Fq "sha256sums = ${{ steps.release.outputs.sha256 }}" .SRCINFO - name: Commit and push to AUR working-directory: aur-curd env: GIT_SSH_COMMAND: ssh -i ~/.ssh/curd_aur_deploy -o IdentitiesOnly=yes run: | set -euo pipefail git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if git diff --quiet; then echo "No changes to commit" exit 0 fi git add PKGBUILD .SRCINFO git commit -m "Update to version ${{ steps.release.outputs.version }}" git push origin HEAD:master - name: Cleanup if: always() run: | rm -f ~/.ssh/curd_aur_deploy rm -f ~/.ssh/curd_aur_deploy.pub ================================================ FILE: Build/buildlinux ================================================ #!/bin/bash ./Build/buildlinux-arm64 ./Build/buildlinux-x86_64 ================================================ FILE: Build/buildlinux-arm64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o curd-linux-arm64 -ldflags="-X main.version=${VERSION} -s -w" -trimpath cmd/curd/main.go if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" && "$GITHUB_EVENT_HEAD_COMMIT_MESSAGE" == *"release:"* ]] || [[ "$COMPRESS" == "true" ]]; then upx --best --ultra-brute curd-linux-arm64 fi ================================================ FILE: Build/buildlinux-x86_64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o curd-linux-x86_64 -ldflags="-X main.version=${VERSION} -s -w" -trimpath cmd/curd/main.go if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" && "$GITHUB_EVENT_HEAD_COMMIT_MESSAGE" == *"release:"* ]] || [[ "$COMPRESS" == "true" ]]; then upx --best --ultra-brute curd-linux-x86_64 fi ================================================ FILE: Build/buildmac ================================================ #!/bin/bash ./Build/buildmac-arm64 ./Build/buildmac-x86_64 ================================================ FILE: Build/buildmac-arm64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o curd-macos-arm64 -ldflags="-X main.version=${VERSION}" cmd/curd/main.go ================================================ FILE: Build/buildmac-x86_64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -o curd-macos-x86_64 -ldflags="-X main.version=${VERSION}" -trimpath cmd/curd/main.go ================================================ FILE: Build/buildwindows ================================================ #!/bin/bash # Set required Windows build environment variables export CGO_ENABLED=1 export GOOS=windows # Build for ARM64 (Broken due to npipe and rich-go) # export GOARCH=arm64 # CC=x86_64-w64-mingw32-gcc ./Build/buildwindows-arm64 # Build for x86_64 export GOARCH=amd64 CC=x86_64-w64-mingw32-gcc ./Build/buildwindows-x86_64 ================================================ FILE: Build/buildwindows-arm64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=windows GOARCH=arm64 go build -o curd-windows-arm64.exe -ldflags="-X main.version=${VERSION}" cmd/curd/main.go if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" && "$GITHUB_EVENT_HEAD_COMMIT_MESSAGE" == *"release:"* ]] || [[ "$COMPRESS" == "true" ]]; then upx --best --ultra-brute curd-windows-arm64.exe fi ================================================ FILE: Build/buildwindows-x86_64 ================================================ VERSION=$(cat VERSION.txt) CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o curd-windows-x86_64.exe -ldflags="-X main.version=${VERSION}" cmd/curd/main.go if [[ "$GITHUB_EVENT_NAME" == "push" && "$GITHUB_REF" == "refs/heads/main" && "$GITHUB_EVENT_HEAD_COMMIT_MESSAGE" == *"release:"* ]] || [[ "$COMPRESS" == "true" ]]; then upx --best --ultra-brute curd-windows-x86_64.exe fi ================================================ FILE: Build/curd-windows-build.iss ================================================ [Setup] AppName=Curd Installer AppVersion=1.4.0 DefaultDirName={userappdata}\Curd PrivilegesRequired=lowest AllowNoIcons=yes OutputBaseFilename=curd-windows-installer UsePreviousAppDir=yes Compression=lzma2 SolidCompression=yes [Tasks] ; Define a task for creating a desktop shortcut Name: "desktopicon"; Description: "Create a &desktop shortcut"; GroupDescription: "Additional Options"; [Files] ; Copy the Curd executable to the install directory Source: "..\releases\curd-{#SetupSetting("AppVersion")}\windows\curd-windows-x86_64.exe"; DestDir: "{app}"; DestName: "curd.exe"; Flags: ignoreversion Source: "mpv\mpv.exe"; DestDir: "{app}\bin"; Flags: ignoreversion [Icons] ; Create the application icon in the Start Menu Name: "{group}\Curd"; Filename: "{app}\curd.exe" ; Create a desktop shortcut if the user checked the option Name: "{userdesktop}\Curd"; Filename: "{app}\curd.exe"; Tasks: desktopicon ================================================ FILE: Build/release ================================================ #!/bin/bash # Ask for version number read -p "Enter the version number: " version release_folder="releases/curd-$version" windows_folder="$release_folder/windows" linux_folder="$release_folder/linux" macos_folder="$release_folder/macos" installer_script="Build/curd-windows-build.iss" # Ensure required directories exist mkdir -p "$windows_folder" "$linux_folder" "$macos_folder" # Build Linux binary echo "Building Linux binary..." bash Build/buildlinux # Move the Linux binary to the release folder if [ -f "curd-linux-x86_64" ]; then mv curd-linux-x86_64 "$linux_folder" else echo "Linux build failed. Please check Build/buildlinux." exit 1 fi if [ -f "curd-linux-arm64" ]; then mv curd-linux-arm64 "$linux_folder" else echo "Linux build failed. Please check Build/buildlinux-arm64." exit 1 fi # Build macOS binaries echo "Building macOS binaries..." bash Build/buildmac if [ -f "curd-macos-x86_64" ]; then mv curd-macos-x86_64 "$macos_folder" else echo "macOS x86-64 build failed. Please check Build/buildmac-x86_64." exit 1 fi if [ -f "curd-macos-arm64" ]; then mv curd-macos-arm64 "$macos_folder" else echo "macOS arm64 build failed. Please check Build/buildmac-arm64." exit 1 fi # Create Universal binary for macOS echo "Creating macOS Universal binary..." llvm-lipo -create "$macos_folder/curd-macos-x86_64" "$macos_folder/curd-macos-arm64" -output "$macos_folder/curd-macos-universal" # rm "$macos_folder/curd-macos-x86_64" "$macos_folder/curd-macos-arm64" if [ ! -f "$macos_folder/curd-macos-universal" ]; then echo "macOS arm64 build failed. Please check Build/buildmac." exit 1 fi # Update version in the installer script sed -i "s/^AppVersion=.*/AppVersion=$version/" "$installer_script" # Build Windows binary echo "Building Windows binary..." bash Build/buildwindows # Uncompress mpv.exe.gz echo "Uncompressing mpv.exe..." mkdir -p "Build/mpv" gunzip -c Build/mpv.exe.gz > "Build/mpv/mpv.exe" # Move the Windows binary to the release folder if [ -f "curd-windows-x86_64.exe" ]; then mv curd-windows-x86_64.exe "$windows_folder" else echo "Windows build failed. Please check Build/buildwindows-x86_64." exit 1 fi # NOTE: Broken due to npipe and rich-go # if [ -f "curd-windows-arm64.exe" ]; then # mv curd-windows-arm64.exe "$windows_folder" # else # echo "Windows build failed. Please check Build/buildwindows-arm64." # exit 1 # fi # Create Windows installer with Inno Setup echo "Creating Windows installer..." wine "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" "$installer_script" # Move installer to the release folder installer_output="Build/Output/curd-windows-installer.exe" if [ -f "$installer_output" ]; then mv "$installer_output" "$windows_folder" else echo "Installer creation failed. Please check Inno Setup script output." fi rm "Build/mpv/mpv.exe" echo "Release build completed in $release_folder." ================================================ FILE: Build/requirements.txt ================================================ mingw-w64-gcc # Build arm64 windows binary llvm-lipo # Build universal macos binary ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . ================================================ FILE: PKGBUILD ================================================ # Maintainer: Wraient pkgname='curd' pkgver=1.1.4 pkgrel=1 pkgdesc="Watch anime in CLI with AniList Tracking, Discord RPC, Intro/Outro/Filler/Recap Skipping, etc." arch=('x86_64') url="https://github.com/Wraient/curd" license=('GPL') depends=('mpv' 'rofi' 'ueberzugpp') provides=('curd') conflicts=('curd') source=("https://github.com/Wraient/curd/releases/download/v${pkgver}/curd-linux-x86_64") sha256sums=('SKIP') package() { install -Dm755 "$srcdir/curd-linux-x86_64" "$pkgdir/usr/bin/curd" } ================================================ FILE: README.md ================================================ # Curd A cli application to stream anime with [Anilist](https://anilist.co/) integration and Discord RPC written in golang. Works on Linux, MacOS and Windows. ## Join the discord server https://discord.gg/rrpBfu2gHq ## Join the Matrix server https://matrix.to/#/#curd:matrix.org ## Demo Video Normal mode: https://github.com/user-attachments/assets/376e7580-b1af-40ee-82c3-154191f75b79 Rofi with Image preview https://github.com/user-attachments/assets/cbf799bc-9fdd-4402-ab61-b4e31f1e264d ## Features - Multiple Content Providers (AllAnime and Animepahe) with up to 1080p support - Built-in headless browser to bypass Cloudflare/DDoS-Guard protections - Stream anime online - Update anime in Anilist after completion - Skip anime Intro and Outro - Skip Filler and Recap episodes - Discord RPC about the anime - Rofi support - Image preview in rofi - Local anime history to continue from where you left off last time - Save mpv speed for next episode - Configurable through config file ## Installing and Setup > **Note**: `Curd` requires `mpv`, `rofi`, and `ueberzugpp` for Rofi support and image preview. These are included in the installation instructions below for each distribution. ### Linux
Arch Linux / Manjaro (AUR-based systems) Using Yay: ```bash yay -Sy curd ``` or using Paru: ```bash paru -Sy curd ``` Or, to manually clone and install: ```bash git clone https://aur.archlinux.org/curd.git cd curd makepkg -si sudo pacman -S rofi ueberzugpp ```
Debian / Ubuntu (and derivatives) ```bash sudo apt update sudo apt install mpv curl rofi ueberzugpp # For x86_64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-x86_64 # For ARM64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-arm64 chmod +x curd sudo mv curd /usr/bin/ curd ```
Fedora Installation ```bash sudo dnf update sudo dnf install mpv curl rofi ueberzugpp # For x86_64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-x86_64 # For ARM64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-arm64 chmod +x curd sudo mv curd /usr/bin/ curd ```
openSUSE Installation ```bash sudo zypper refresh sudo zypper install mpv curl rofi ueberzugpp # For x86_64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-x86_64 # For ARM64 systems: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-arm64 chmod +x curd sudo mv curd /usr/bin/ curd ```
NixOS Installation 1. Add curd as a flake input, for example: ```nix { inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; curd = { url = "github:Wraient/curd"; inputs.nixpkgs.follows = "nixpkgs"; }; } } ``` 2. Install the package, for example: ```nix {inputs, pkgs, ...}: { environment.systemPackages = [ inputs.curd.packages.${pkgs.system}.default ]; } ```
Generic Installation Choose the appropriate binary for your system: ```bash # For Linux x86_64: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-x86_64 # For Linux ARM64: curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-linux-arm64 chmod +x curd sudo mv curd /usr/bin/ curd ```
Uninstallation ```bash sudo rm /usr/bin/curd ``` For AUR-based distributions: ```bash yay -R curd ```
### MacOS
MacOS Installation Install required dependencies ```bash brew install mpv curl ``` Download the appropriate binary for your system: - For Apple Silicon (M1/M2) Macs: ```bash curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-macos-arm64 ``` - For Intel Macs: ```bash curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-macos-x86_64 ``` - For Universal Binary (works on both architectures): ```bash curl -Lo curd https://github.com/Wraient/curd/releases/latest/download/curd-macos-universal ``` Then complete the installation: ```bash chmod +x curd sudo mv curd /usr/local/bin/ curd ```
Uninstallation ```bash sudo rm /usr/local/bin/curd ```
### Windows
Windows Installation Option 1: Using the installer - Download and run the [Windows Installer](https://github.com/Wraient/curd/releases/latest/download/curd-windows-installer.exe) Option 2: Standalone executable - Download [curd-windows-x86_64.exe](https://github.com/Wraient/curd/releases/latest/download/curd-windows-x86_64.exe)
## Data Storage
Windows Stroage: (Token, Timestamps, debug.log, etc) ```bash C:\.local\share\curd ``` Config : ```bash C:\Users\USERNAME\AppData\Roaming\Curd ```
Linux/Unix Stroage: (Token, Timestamps, debug.log, etc) ```bash $USER/.local/share/curd ``` Config : ```bash $USER/.config/curd ```
## Usage Run `curd` with the following options: ```bash curd [options] ``` ### Arguments would always take precedence over configuration > **Note**: > - To use rofi you need rofi and ueberzug installed. > - Rofi .rasi files are at default `~/.local/share/curd/` > - You can edit them as you like. > - If there are no rasi files with specific names, they would be downloaded from this repo. ### Options | Flag | Description | Default | |---------------------------|-------------------------------------------------------------------------|---------------| | `-c` | Continue the last episode | - | | `-change-token` | Change your authentication token | - | | `-dub` | Watch the dubbed version of the anime | - | | `-sub` | Watch the subbed version of the anime | - | | `-new` | Add a new anime to your list | - | | `-e` | Edit the configuration file | - | | `-skip-op` | Automatically skip the opening section of each episode | `true` | | `-skip-ed` | Automatically skip the ending section of each episode | `true` | | `-skip-filler` | Automatically skip filler episodes | `true` | | `-skip-recap` | Automatically skip recap sections | `true` | | `-discord-presence` | Enable or disable Discord presence | `true` | | `-image-preview` | Show an image preview of the anime | - | | `-no-image-preview` | Disable image preview | - | | `-next-episode-prompt` | Prompt for the next episode after completing one | - | | `-rofi` | Open anime selection in the rofi interface | - | | `-no-rofi` | Disable rofi interface | - | | `-percentage-to-mark-complete` | Set the percentage watched to mark an episode as complete | `85` | | `-player` | Specify the player to use for playback | `"mpv"` | | `-save-mpv-speed` | Save the current MPV speed setting for future sessions | `true` | | `-score-on-completion` | Prompt to score the episode on completion | `true` | | `-storage-path` | Path to the storage directory | `"$HOME/.local/share/curd"` | | `-subs-lang` | Set the language for subtitles | `"english"` | | `-u` | Update the script | - | | `-v` | Show curd version | - | ### Examples - **Continue the Last Episode**: ```bash curd -c ``` - **Add a New Anime**: ```bash curd -percentage-to-mark-complete=90 ``` - **Play with Rofi and Image Preview**: ```bash curd -rofi -image-preview ``` ## Configuration All configurations are stored in a file you can edit with the `-e` option. ```bash curd -e ``` Script is made in a way that you use it for one session of watching. 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``` | **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 | | `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. | | `PercentageToMarkComplete`| Integer | `0` to `100` | Sets the percentage of an episode watched to consider it as completed. | | `StoragePath` | String | Any valid path (Environment variables accepted) | Specifies the directory where Curd stores its data. | | `SubOrDub` | Enum | `sub`, `dub` | Sets the preferred format for anime audio. | | `NextEpisodePrompt` | Boolean | `true`, `false` | Prompts the user before automatically playing the next episode. | | `SubsLanguage` | String | `english` (redundant rn) | Sets the preferred subtitle language. | | `ScoreOnCompletion` | Boolean | `true`, `false` | Automatically prompts the user to rate the anime upon completion. | | `SkipOp` | Boolean | `true`, `false` | Automatically skips the opening of episodes when supported. | | `SkipEd` | Boolean | `true`, `false` | Automatically skips the ending of episodes when supported. | | `SkipRecap` | Boolean | `true`, `false` | Skips recap sections in episodes when supported. | | `ImagePreview` | Boolean | `true`, `false` | Enables or disables image previews during anime selection (only for rofi). | | `Player` | String | any mpv-compatible binary (e.g. `mpv`, `iina`) | Player binary used for playback. If not found, Curd falls back to `mpv`. | | `SaveMpvSpeed` | Boolean | `true`, `false` | Retains the playback speed set in MPV for next episode. | | `SkipFiller` | Boolean | `true`, `false` | Skips filler episodes when supported. | | `MenuOrder` | String | Comma-separated list | Controls which menu items appear and their order. Available options: `CURRENT`, `ALL`, `UNTRACKED`, `UPDATE`, `CONTINUE_LAST`, `PLANNING`, `COMPLETED`, `PAUSED`, `DROPPED`, `REWATCHING`, `PROVIDER`. Only listed items will be shown. Default: `CURRENT,ALL,UNTRACKED,UPDATE,CONTINUE_LAST,PROVIDER` | | `Provider` | Enum | `allanime`, `animepahe` | Sets the content provider for anime streams. `animepahe` requires chromium to bypass DDoS-Guard. Default: `allanime` | ## Todo (fix) - Use Powershell for windows token input instead of notepad or cmd - Add a better way to do commands in windows (Convinience for users) ## Dependencies - mpv - Video player (required fallback) - iina - Optional mpv-based player on macOS - rofi - Selection menu - ueberzug - Display images in rofi - chromium - Required for Animepahe (auto-downloaded by default, but Termux users must install manually via `pkg install chromium`) ## API Used - [Anilist API](https://anilist.gitbook.io/anilist-apiv2-docs) - Update user data and download user data - [AniSkip API](https://api.aniskip.com/api-docs) - Get anime intro and outro timings - [AllAnime Content](https://allanime.to/) - Fetch anime url - [Animepahe Content](https://animepahe.pw/) - Alternative provider for 1080p streams - [Jikan](https://jikan.moe/) - Get filler episode number ## Credits - [ani-cli](https://github.com/pystardust/ani-cli) - Code for fetching anime url - [jerry](https://github.com/justchokingaround/jerry) - For the inspiration ================================================ FILE: VERSION.txt ================================================ 1.4.0 ================================================ FILE: cmd/curd/main.go ================================================ package main import ( "flag" "fmt" "os" "path/filepath" "runtime" "strconv" "sync" "time" "github.com/wraient/curd/internal" ) var version string // Will be set by ldflags during build func resolvedVersion() string { if version == "" { return "1.4.0" } return version } func main() { var anime internal.Anime var user internal.User internal.SetGlobalAnime(&anime) configDir, err := os.UserConfigDir() if err != nil { // Fallback if UserConfigDir fails if runtime.GOOS == "windows" { configDir = filepath.Join(os.Getenv("USERPROFILE"), "AppData", "Roaming") } else { configDir = filepath.Join(os.Getenv("HOME"), ".config") } } configFilePath := filepath.Join(configDir, "curd", "curd.conf") // load curd userCurdConfig userCurdConfig, err := internal.LoadConfig(configFilePath) if err != nil { fmt.Println("Error loading config:", err) return } internal.SetGlobalConfig(&userCurdConfig) logFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "debug.log") internal.SetGlobalLogFile(logFile) internal.ClearLogFile(logFile) // Flags configured here cause userconfig needs to be changed. flag.StringVar(&userCurdConfig.Player, "player", userCurdConfig.Player, "Player binary for playback (mpv-compatible; falls back to mpv if unavailable)") flag.StringVar(&userCurdConfig.StoragePath, "storage-path", userCurdConfig.StoragePath, "Path to the storage directory") flag.StringVar(&userCurdConfig.SubsLanguage, "subs-lang", userCurdConfig.SubsLanguage, "Subtitles language") flag.IntVar(&userCurdConfig.PercentageToMarkComplete, "percentage-to-mark-complete", userCurdConfig.PercentageToMarkComplete, "Percentage to mark episode as complete") // Boolean flags that accept true/false flag.BoolVar(&userCurdConfig.NextEpisodePrompt, "next-episode-prompt", userCurdConfig.NextEpisodePrompt, "Prompt for the next episode (true/false)") flag.BoolVar(&userCurdConfig.SkipOp, "skip-op", userCurdConfig.SkipOp, "Skip opening (true/false)") flag.BoolVar(&userCurdConfig.SkipEd, "skip-ed", userCurdConfig.SkipEd, "Skip ending (true/false)") flag.BoolVar(&userCurdConfig.SkipFiller, "skip-filler", userCurdConfig.SkipFiller, "Skip filler episodes (true/false)") flag.BoolVar(&userCurdConfig.SkipRecap, "skip-recap", userCurdConfig.SkipRecap, "Skip recap (true/false)") flag.BoolVar(&userCurdConfig.ScoreOnCompletion, "score-on-completion", userCurdConfig.ScoreOnCompletion, "Score on episode completion (true/false)") flag.BoolVar(&userCurdConfig.SaveMpvSpeed, "save-mpv-speed", userCurdConfig.SaveMpvSpeed, "Save MPV speed setting (true/false)") flag.BoolVar(&userCurdConfig.DiscordPresence, "discord-presence", userCurdConfig.DiscordPresence, "Enable Discord presence (true/false)") flag.StringVar(&userCurdConfig.DiscordClientId, "discord-client-id", userCurdConfig.DiscordClientId, "Discord client ID for Rich Presence") continueLast := flag.Bool("c", false, "Continue last episode") addNewAnime := flag.Bool("new", false, "Add new anime") rofiSelection := flag.Bool("rofi", false, "Open selection in rofi") noRofi := flag.Bool("no-rofi", false, "No rofi") imagePreview := flag.Bool("image-preview", false, "Show image preview") noImagePreview := flag.Bool("no-image-preview", false, "No image preview") changeToken := flag.Bool("change-token", false, "Change token") currentCategory := flag.Bool("current", false, "Current category") updateScript := flag.Bool("u", false, "Update the script") editConfig := flag.Bool("e", false, "Edit config") subFlag := flag.Bool("sub", false, "Watch sub version") dubFlag := flag.Bool("dub", false, "Watch dub version") versionFlag := flag.Bool("v", false, "Print version information") // Custom help/usage function flag.Usage = func() { internal.RestoreScreen() fmt.Fprintf(os.Stderr, "Curd is a CLI tool to manage anime playback with advanced features like skipping intro, outro, filler, recap, tracking progress, and integrating with Discord.\n") fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0]) flag.PrintDefaults() // This prints the default flag information } flag.Parse() // Validate PercentageToMarkComplete range (0-100) from CLI flag if userCurdConfig.PercentageToMarkComplete < 0 { userCurdConfig.PercentageToMarkComplete = 0 } else if userCurdConfig.PercentageToMarkComplete > 100 { userCurdConfig.PercentageToMarkComplete = 100 } // Check version before screen clearing if *versionFlag { fmt.Printf("Curd version: %s\n", resolvedVersion()) os.Exit(0) } anime.Ep.ContinueLast = *continueLast if *updateScript { repo := "wraient/curd" fileName := "curd" if err := internal.UpdateCurd(repo, fileName); err != nil { internal.CurdOut(fmt.Sprintf("Error updating executable: %v\n", err)) internal.ExitCurd(err) } else { internal.CurdOut("Program Updated!") internal.ExitCurd(nil) } } if *changeToken { internal.ChangeToken(&userCurdConfig, &user) return } // Setup screen for interactive mode (only if not changing token) internal.ClearScreen() defer internal.RestoreScreen() if *currentCategory { userCurdConfig.CurrentCategory = true } if *rofiSelection { userCurdConfig.RofiSelection = true } if *noRofi || runtime.GOOS == "windows" { userCurdConfig.RofiSelection = false } if *imagePreview { userCurdConfig.ImagePreview = true } if *noImagePreview || runtime.GOOS == "windows" { userCurdConfig.ImagePreview = false } if *editConfig { internal.EditConfig(configFilePath) return } // Set SubOrDub based on the flags if *subFlag { userCurdConfig.SubOrDub = "sub" } else if *dubFlag { userCurdConfig.SubOrDub = "dub" } // Get the token from the token file user.Token, err = internal.GetTokenFromFile(filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "anilist_token.json")) if err != nil { internal.Log("Error reading token") } if user.Token == "" { internal.ChangeToken(&userCurdConfig, &user) } if userCurdConfig.RofiSelection { // Define a slice of file names to check and download filesToCheck := []string{ "selectanimepreview.rasi", "selectanime.rasi", "userinput.rasi", } // Call the function to check and download files err := internal.CheckAndDownloadFiles(os.ExpandEnv(userCurdConfig.StoragePath), filesToCheck) if err != nil { internal.Log(fmt.Sprintf("Error checking and downloading files: %v\n", err)) internal.CurdOut(fmt.Sprintf("Error checking and downloading files: %v\n", err)) internal.ExitCurd(err) } } // Load animes in database databaseFile := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_history.txt") databaseAnimes := internal.LocalGetAllAnime(databaseFile) if *addNewAnime { internal.AddNewAnime(&userCurdConfig, &anime, &user, &databaseAnimes) // 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()) } if anime.TotalEpisodes == temp_anime.Progress && temp_anime.Status != "CURRENT" { 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) } }() // Main loop (loop to keep starting new episodes) for { internal.Log(anime) // Create a channel to signal when to exit the skip loop var wg sync.WaitGroup skipLoopDone := make(chan struct{}) skipLoopClosed := make(chan bool, 1) // Channel to track if skipLoopDone has been closed skipLoopClosed <- false // Initialize to false (not closed yet) // Get MalId and CoverImage (only if discord presence is enabled) if userCurdConfig.DiscordPresence { anime.MalId, anime.CoverImage, err = internal.GetAnimeIDAndImage(anime.AnilistId) if err != nil { internal.Log("Error getting anime ID and image: " + err.Error()) } // Skip initial Discord presence - wait for MPV to provide real duration // This avoids showing the default 25-minute duration before the video starts internal.Log("Waiting for MPV to start to get actual video duration before showing Discord presence") } else if anime.MalId == 0 { anime.MalId, err = internal.GetAnimeMalID(anime.AnilistId) if err != nil { internal.Log("Error getting anime MAL ID: " + err.Error()) } } // Start curd (loop while episode is playing) for { // Check if current episode is filler/recap err = internal.GetEpisodeData(anime.MalId, anime.Ep.Number, &anime) if err != nil { internal.Log("Error getting episode data, assuming non-filler: " + err.Error()) break // Break the loop and continue with playback } // Check if episode is filler anime.Ep.IsFiller = internal.IsEpisodeFiller(anime.FillerEpisodes, anime.Ep.Number) // 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) } break } // If it is filler/recap, log it and move to next episode if anime.Ep.IsFiller { internal.CurdOut(fmt.Sprint("Filler episode, skipping: ", anime.Ep.Number)) // Get next canon episode anime.Ep.Number = internal.GetNextCanonEpisode(anime.FillerEpisodes, anime.Ep.Number) } else { internal.CurdOut(fmt.Sprint("Recap episode, skipping: ", anime.Ep.Number)) anime.Ep.Number++ } anime.Ep.LastWasSkipped = true anime.Ep.Started = false internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, 0, 0, internal.GetAnimeName(anime), internal.GetProvider().Name()) // Check if we've reached the end of the series if anime.Ep.Number > anime.TotalEpisodes { internal.CurdOut("Reached end of series") internal.ExitCurd(nil) } } // Now start playback for the non-filler episode anime.Ep.Player.SocketPath = internal.StartCurd(&userCurdConfig, &anime) internal.Log(fmt.Sprint("Playback starting time: ", anime.Ep.Player.PlaybackTime)) internal.Log(anime.Ep.Player.SocketPath) // Handle Android Intent external player if anime.Ep.Player.SocketPath == "android-intent" { internal.CurdOut(fmt.Sprintf("\nOpened external player for Episode %d.", anime.Ep.Number)) internal.CurdOut("Press Enter when you have finished watching...") // Wait for user input to confirm completion var input string fmt.Scanln(&input) // Mark as completed anime.Ep.IsCompleted = true // Update progress for the finished episode // Local update internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, 0, 0, internal.GetAnimeName(anime), internal.GetProvider().Name()) // Check if we should continue to next episode // On Android we always prompt because we don't know exactly when video ended shouldContinue := internal.NextEpisodePromptCLI(&userCurdConfig) if shouldContinue { internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) continue } else { // Handle completion if this was the last episode if anime.Ep.Number == anime.TotalEpisodes { internal.HandleLastEpisodeCompletion(&userCurdConfig, &anime, user.Token) } // Update progress for the just finished episode (StartNextEpisode usually does this for previous ep, but here we exit) if !anime.Rewatching { internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) } internal.ExitCurd(nil) } } wg.Add(1) // Get episode data go func() { defer wg.Done() err = internal.GetEpisodeData(anime.MalId, anime.Ep.Number, &anime) if err != nil { internal.Log("Error getting episode data: " + err.Error()) } else { internal.Log(anime) // if filler episode or recap episode and skip is enabled if (anime.Ep.IsFiller && userCurdConfig.SkipFiller) || (anime.Ep.IsRecap && userCurdConfig.SkipRecap) { if anime.Ep.IsFiller && userCurdConfig.SkipFiller { internal.CurdOut(fmt.Sprint("Filler Episode, starting next episode: ", anime.Ep.Number+1)) internal.Log("Filler episode detected") } else if anime.Ep.IsRecap && userCurdConfig.SkipRecap { internal.CurdOut(fmt.Sprint("Recap Episode, starting next episode: ", anime.Ep.Number+1)) internal.Log("Recap episode detected") } anime.Ep.IsCompleted = true if !userCurdConfig.NextEpisodePrompt { // fmt.Println("[DEBUG] Starting next episode from filler/recap skip") internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) } else { // When NextEpisodePrompt is enabled, just call StartNextEpisode - it handles Rofi prompting internally internal.ExitMPV(anime.Ep.Player.SocketPath) internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) return } // Send command to close MPV _, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"quit"}) if err != nil { internal.Log("Error closing MPV: " + err.Error()) } // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } } } }() wg.Add(1) // Thread to update Discord presence with simple position-gap seek detection go func() { defer wg.Done() if userCurdConfig.DiscordPresence { var lastKnownPauseState bool = false var lastKnownPosition int = 0 var lastStateCheck time.Time var discordPresenceInitialized bool = false // Track if Discord presence has been set with real duration for { select { case <-skipLoopDone: return default: // Get current state from MPV isPaused, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "pause"}) if err != nil { internal.Log("Error getting pause status: " + err.Error()) time.Sleep(5 * time.Second) continue } if isPaused == nil { isPaused = true } // Get current time position currentPos := 0 timePos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"}) if err == nil && timePos != nil { if pos, ok := timePos.(float64); ok { currentPos = int(pos + 0.5) // Round to nearest integer } } currentPauseState := isPaused.(bool) // Simple seek detection: position gap > 5 seconds hasSeekEvent := false if lastKnownPosition > 0 { positionDiff := currentPos - lastKnownPosition if positionDiff < -5 || positionDiff > 7 { // 5 sec backward or 7 sec forward (allowing normal playback + buffer) hasSeekEvent = true } } hasPlayPauseEvent := currentPauseState != lastKnownPauseState // Determine if we should update Discord presence shouldUpdate := false // Force update every 30 seconds for Discord keep-alive if lastStateCheck.IsZero() || time.Since(lastStateCheck) >= 30*time.Second { shouldUpdate = true } // Update on pause state change if hasPlayPauseEvent { shouldUpdate = true } // Update on seek events if hasSeekEvent { shouldUpdate = true } if shouldUpdate { // Only update Discord if we have real duration OR if presence was already initialized totalDuration := anime.Ep.Duration if totalDuration == 0 { // Skip Discord updates until we have real duration from MPV if !discordPresenceInitialized { lastKnownPauseState = currentPauseState lastKnownPosition = currentPos lastStateCheck = time.Now() time.Sleep(2 * time.Second) continue } totalDuration = currentPos + 1 // Small duration to avoid divide by zero } else { discordPresenceInitialized = true // Mark as initialized once we have real duration } // Force update on seek events to bypass Discord's internal filtering if hasSeekEvent { err = internal.DiscordPresenceWithForce(anime, currentPauseState, currentPos, totalDuration, userCurdConfig.DiscordClientId, true) } else { err = internal.DiscordPresence(anime, currentPauseState, currentPos, totalDuration, userCurdConfig.DiscordClientId) } if err != nil { internal.Log("Error setting Discord presence: " + err.Error()) } lastKnownPauseState = currentPauseState lastStateCheck = time.Now() } // Always update position for next comparison lastKnownPosition = currentPos time.Sleep(2 * time.Second) // Check every 2 seconds } } } }() // Get skip times Parallel go func() { err = internal.GetAndParseAniSkipData(anime.MalId, anime.Ep.Number, 1, &anime) if err != nil { internal.Log("Error getting and parsing AniSkip data: " + err.Error()) } internal.Log(anime.Ep.SkipTimes) }() // Get video duration go func() { for { if anime.Ep.Started { if anime.Ep.Duration == 0 { // Get video duration durationPos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "duration"}) if err != nil { internal.Log("Error getting video duration: " + err.Error()) } else if durationPos != nil { if duration, ok := durationPos.(float64); ok { anime.Ep.Duration = int(duration + 0.5) // Round to nearest integer internal.Log(fmt.Sprintf("Video duration: %d seconds", anime.Ep.Duration)) // Initialize Discord presence with correct duration (first time with real duration) if userCurdConfig.DiscordPresence { isPaused, _ := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "pause"}) currentPos := 0 if timePos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"}); err == nil && timePos != nil { if pos, ok := timePos.(float64); ok { currentPos = int(pos + 0.5) } } pauseState := false if isPaused != nil { pauseState = isPaused.(bool) } internal.Log("Initializing Discord presence with real video duration") err = internal.DiscordPresence(anime, pauseState, currentPos, anime.Ep.Duration, userCurdConfig.DiscordClientId) if err != nil { internal.Log("Discord presence error: " + err.Error()) } } } else { internal.Log("Error: duration is not a float64") } } break } } time.Sleep(1 * time.Second) } }() // Thread for continuous next episode prompt in CLI mode (throughout episode duration) go func() { if userCurdConfig.NextEpisodePrompt && !userCurdConfig.RofiSelection { internal.NextEpisodePromptContinuous(&userCurdConfig, databaseFile, user.Token) // If the function returns, it means user made a decision // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } } }() wg.Add(1) // Thread to update playback time in database go func() { defer wg.Done() for { select { case <-skipLoopDone: return default: time.Sleep(1 * time.Second) // Get current playback time // internal.Log("Getting playback time "+anime.Ep.Player.SocketPath) timePos, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"}) if err != nil { internal.Log("Error getting playback time: " + err.Error()) // For CLI mode with next episode prompt, let the continuous prompt handle everything if userCurdConfig.NextEpisodePrompt && !userCurdConfig.RofiSelection { continue } // Check if the error is due to invalid JSON // User closed the video if anime.Ep.Started { percentageWatched := internal.PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration) // Episode is completed internal.Log(fmt.Sprint(percentageWatched)) internal.Log(fmt.Sprint(anime.Ep.Player.Speed)) internal.Log(fmt.Sprint(anime.Ep.Player.PlaybackTime)) internal.Log(fmt.Sprint(anime.Ep.Duration)) internal.Log(fmt.Sprint(userCurdConfig.PercentageToMarkComplete)) if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete { anime.Ep.IsCompleted = true if !userCurdConfig.NextEpisodePrompt { internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) } else { // For Rofi mode, show prompt immediately after completion if userCurdConfig.RofiSelection { shouldContinue := internal.NextEpisodePromptRofi(&userCurdConfig) if shouldContinue { internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) } else { // Episode was already marked as completed above // Handle completion if this was the last episode if anime.Ep.Number == anime.TotalEpisodes { internal.HandleLastEpisodeCompletion(&userCurdConfig, &anime, user.Token) } // Update local database with completed episode err := internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime), internal.GetProvider().Name()) if err != nil { internal.Log("Error updating local database on quit: " + err.Error()) } // Update Anilist progress if not rewatching if !anime.Rewatching { err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { internal.Log("Error updating Anilist progress on quit: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } } internal.ExitCurd(nil) } } else { // For CLI mode, let the continuous prompt handle it internal.Log("Episode completed, exiting monitoring to let CLI prompt handle next episode") } // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } return } } else { internal.Log("Episode is not completed, exiting") internal.ExitCurd(nil) } // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } return } } // Convert timePos to integer if timePos != nil { if !anime.Ep.Started { anime.Ep.Started = true // Set the playback speed if userCurdConfig.SaveMpvSpeed { speedCmd := []interface{}{"set_property", "speed", anime.Ep.Player.Speed} _, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, speedCmd) if err != nil { internal.Log("Error setting playback speed: " + err.Error()) } } // Apply OP/ED Chapters err = internal.SendSkipTimesToMPV(&anime) if err != nil { internal.Log("Error sending skip times to MPV: " + err.Error()) } } // If resume is true, seek to the playback time if anime.Ep.Resume { internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.Player.PlaybackTime) anime.Ep.Resume = false } animePosition, ok := timePos.(float64) if !ok { internal.Log("Error: timePos is not a float64") continue } anime.Ep.Player.PlaybackTime = int(animePosition + 0.5) // Round to nearest integer // Update Local Database err = internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime), internal.GetProvider().Name()) if err != nil { internal.Log("Error updating local database: " + err.Error()) } } // Check if anything is playing, if nothing is playing and episode was started, handle completion hasPlayback, err := internal.HasActivePlayback(anime.Ep.Player.SocketPath) if err != nil { internal.Log("Error checking playback status: " + err.Error()) } else if !hasPlayback && anime.Ep.Started { // Wait for a moment to allow playback to start time.Sleep(2 * time.Second) // Wait for 2 seconds // Check playback status again hasPlayback, err = internal.HasActivePlayback(anime.Ep.Player.SocketPath) if err != nil { internal.Log("Error checking playback status: " + err.Error()) } else if !hasPlayback { // For CLI mode with next episode prompt, let the continuous prompt handle everything if userCurdConfig.NextEpisodePrompt && !userCurdConfig.RofiSelection { continue } // Nothing is playing, check percentage watched percentageWatched := internal.PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration) // fmt.Printf("[DEBUG] Playback stopped - Percentage watched: %d%%, Required: %d%%\n", // int(percentageWatched), // userCurdConfig.PercentageToMarkComplete) if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete { anime.Ep.IsCompleted = true if !userCurdConfig.NextEpisodePrompt { internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) } else { // For Rofi mode, show prompt immediately after completion if userCurdConfig.RofiSelection { shouldContinue := internal.NextEpisodePromptRofi(&userCurdConfig) if shouldContinue { internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) } else { // Episode was already marked as completed above // Update local database with completed episode err := internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime), internal.GetProvider().Name()) if err != nil { internal.Log("Error updating local database on quit: " + err.Error()) } // Update Anilist progress if not rewatching if !anime.Rewatching { err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { internal.Log("Error updating Anilist progress on quit: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } } internal.ExitCurd(nil) } } else { // For CLI mode, update progress immediately since episode is 85%+ complete // Update local database with completed episode err := internal.LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, internal.ConvertSecondsToMinutes(anime.Ep.Duration), internal.GetAnimeName(anime), internal.GetProvider().Name()) if err != nil { internal.Log("Error updating local database on completion: " + err.Error()) } // Update Anilist progress if not rewatching if !anime.Rewatching { err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number) if err != nil { internal.Log("Error updating Anilist progress on completion: " + err.Error()) } else { internal.CurdOut(fmt.Sprintf("Episode completed! Progress updated: %d", anime.Ep.Number)) } } internal.Log("Episode completed, updated progress, exiting monitoring to let CLI prompt handle next episode") } // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } return } } else { internal.Log("Episode is not completed, exiting") internal.ExitCurd(nil) } // Exit the skip loop - only close if not already closed select { case isClosed := <-skipLoopClosed: if !isClosed { close(skipLoopDone) skipLoopClosed <- true // Mark as closed } default: // Channel is busy, another goroutine is handling closure } return } } } } }() // Skip OP and ED and Save MPV Speed skipLoop: for { select { case <-skipLoopDone: // Exit signal received, break out of the skipLoop break skipLoop default: if userCurdConfig.SkipOp { if anime.Ep.Player.PlaybackTime > anime.Ep.SkipTimes.Op.Start && anime.Ep.Player.PlaybackTime < anime.Ep.SkipTimes.Op.Start+2 && anime.Ep.SkipTimes.Op.Start != anime.Ep.SkipTimes.Op.End { internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.SkipTimes.Op.End) } } if userCurdConfig.SkipEd { if anime.Ep.Player.PlaybackTime > anime.Ep.SkipTimes.Ed.Start && anime.Ep.Player.PlaybackTime < anime.Ep.SkipTimes.Ed.Start+2 && anime.Ep.SkipTimes.Ed.Start != anime.Ep.SkipTimes.Ed.End { internal.SeekMPV(anime.Ep.Player.SocketPath, anime.Ep.SkipTimes.Ed.End) } } _, err := internal.MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"}) if err == nil && anime.Ep.Started { anime.Ep.Player.Speed, err = internal.GetMPVPlaybackSpeed(anime.Ep.Player.SocketPath) if err != nil { internal.Log("Failed to get mpv speed " + err.Error()) } } } time.Sleep(1 * time.Second) // Wait before checking again } // Wait for all goroutines to finish before starting the next iteration wg.Wait() // Reset the WaitGroup for the next loop wg = sync.WaitGroup{} // Exit the program if we're starting an episode beyond the total episodes if anime.Ep.Number > anime.TotalEpisodes && anime.TotalEpisodes > 0 { internal.CurdOut("Reached end of series") internal.ExitCurd(nil) } if anime.Ep.IsCompleted && !anime.Rewatching { // Update progress for both regular episodes and skipped fillers 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) if err != nil { internal.Log("Error updating Anilist progress: " + err.Error()) } }() } else { // Update progress for last episode // Exit MPV internal.ExitMPV(anime.Ep.Player.SocketPath) err = internal.UpdateAnimeProgress(user.Token, anime.AnilistId, anime.Ep.Number-1) if err != nil { internal.Log("Error updating Anilist progress: " + err.Error()) } } anime.Ep.IsCompleted = false // Only mark as complete and prompt for rating if we've reached the total episodes // AND the anime is not currently airing (total episodes > 0) 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) 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) if err != nil { internal.Log("Error rating anime: " + err.Error()) internal.CurdOut("Error rating anime: " + err.Error()) } internal.LocalDeleteAnime(databaseFile, anime.AnilistId, anime.ProviderId) internal.ExitCurd(nil) } } } if anime.Rewatching && anime.Ep.IsCompleted && anime.Ep.Number-1 == anime.TotalEpisodes { anime.Ep.Number = anime.Ep.Number - 1 internal.CurdOut("Completed anime. (Rewatching so no scoring)") internal.LocalDeleteAnime(databaseFile, anime.AnilistId, anime.ProviderId) internal.ExitCurd(nil) } // Handle next episode logic based on config if anime.Ep.IsCompleted { if userCurdConfig.NextEpisodePrompt { if !userCurdConfig.RofiSelection { // For CLI mode, the continuous prompt handles everything internal.CurdOut("CLI mode: continuous prompt handling next episode logic") } // For both modes, if we reach here, it means the monitoring thread exited // and the episode should transition. Let the normal flow continue. } else { // When NextEpisodePrompt is off, continue automatically internal.StartNextEpisode(&anime, &userCurdConfig, databaseFile, user.Token) continue } } // Wait for up to 5 seconds for prefetched links to become available for i := 0; i < 5; i++ { if anime.Ep.NextEpisode.Number == anime.Ep.Number && len(anime.Ep.NextEpisode.Links) > 0 { internal.Log("Using prefetched next episode link") anime.Ep.Links = anime.Ep.NextEpisode.Links break } time.Sleep(1 * time.Second) } // If we still don't have links, get them now if len(anime.Ep.Links) == 0 { links, err := internal.GetEpisodeURL(userCurdConfig, anime.ProviderId, anime.Ep.Number) if err != nil { internal.Log("Failed to get episode links: " + err.Error()) internal.CurdOut("Failed to get episode links. Try again later.") internal.ExitCurd(fmt.Errorf("failed to get episode links: %v", err)) return } anime.Ep.Links = links } // Verify that we have links before starting if len(anime.Ep.Links) == 0 { internal.CurdOut("No episode links found. Try again later.") internal.ExitCurd(fmt.Errorf("no episode links found")) return } } } ================================================ FILE: flake.nix ================================================ { description = "Watch anime in cli with Anilist Integration and Discord RPC "; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; systems.url = "github:nix-systems/default"; }; outputs = { nixpkgs, systems, self, ... }: let eachSystem = nixpkgs.lib.genAttrs (import systems); in { packages = eachSystem (system: let package = nixpkgs.legacyPackages.${system}.callPackage ./package.nix {}; in { default = package; curd = package; }); devShells = eachSystem (system: { default = nixpkgs.legacyPackages.${system}.mkShellNoCC { inputsFrom = [self.packages.${system}.default]; }; }); formatter = eachSystem (system: nixpkgs.legacyPackages.${system}.alejandra); }; } ================================================ FILE: go.mod ================================================ module github.com/wraient/curd go 1.21 require ( github.com/Microsoft/go-winio v0.6.2 github.com/charmbracelet/bubbletea v1.3.3 github.com/charmbracelet/lipgloss v1.0.0 github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/tr1xem/go-discordrpc v1.0.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-rod/rod v0.116.2 // indirect github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.15.2 // indirect github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect github.com/ysmood/fetchup v0.2.3 // indirect github.com/ysmood/goob v0.4.0 // indirect github.com/ysmood/got v0.40.0 // indirect github.com/ysmood/gson v0.7.3 // indirect github.com/ysmood/leakless v0.9.0 // indirect golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.3.8 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) ================================================ FILE: go.sum ================================================ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/bubbletea v1.3.3 h1:WpU6fCY0J2vDWM3zfS3vIDi/ULq3SYphZhkAGGvmEUY= github.com/charmbracelet/bubbletea v1.3.3/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= github.com/tr1xem/go-discordrpc v1.0.0 h1:iWW740MP2hkBqjehlvjRtQT7+DDaJ4qQ4o1vo+ImMug= github.com/tr1xem/go-discordrpc v1.0.0/go.mod h1:DD//cKGwNjTSFysXlyjEUN/pSH2Z/HcFAnlOiE7wX/k= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= github.com/ysmood/gotrace v0.6.0/go.mod h1:TzhIG7nHDry5//eYZDYcTzuJLYQIkykJzCRIo4/dzQM= github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= ================================================ FILE: internal/anilist.go ================================================ package internal import ( "bytes" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "sort" "strconv" "strings" ) // FindKeyByValue searches for a key associated with a given value in a map[string]string func FindKeyByValue(m map[string]string, value string) (string, error) { for key, val := range m { if val == value { return key, nil // Return the key and true if the value is found } } return "", fmt.Errorf("no key with value %v", value) // Return empty string and false if the value is not found } // GetAnimeMap takes an AnimeList and returns a map with media.id as key and media.title.english as value. func GetAnimeMap(animeList AnimeList) map[string]string { animeMap := make(map[string]string) userCurdConfig := GetGlobalConfig() // Helper function to populate the map from a slice of entries populateMap := func(entries []Entry) { for _, entry := range entries { // Only include entries with a non-empty English title if entry.Media.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" { animeMap[strconv.Itoa(entry.Media.ID)] = entry.Media.Title.English } else { animeMap[strconv.Itoa(entry.Media.ID)] = entry.Media.Title.Romaji } } } // Populate the map for each category populateMap(animeList.Watching) populateMap(animeList.Completed) populateMap(animeList.Paused) populateMap(animeList.Dropped) populateMap(animeList.Planning) populateMap(animeList.Rewatching) // Add Rewatching list return animeMap } // GetAnimeMapPreview takes an AnimeList and returns a map with media.id as key and media.title.english as value. func GetAnimeMapPreview(animeList AnimeList) map[string]RofiSelectPreview { userCurdConfig := GetGlobalConfig() animeMap := make(map[string]RofiSelectPreview) // Helper function to populate the map from a slice of entries populateMap := func(entries []Entry) { for _, entry := range entries { // Only include entries with a non-empty English title Log(fmt.Errorf("AnimeNameLanguage: %v", userCurdConfig.AnimeNameLanguage)) if entry.Media.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" { animeMap[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{ Title: entry.Media.Title.English, CoverImage: entry.CoverImage, } } else { animeMap[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{ Title: entry.Media.Title.Romaji, CoverImage: entry.CoverImage, } } } } // Populate the map for each category populateMap(animeList.Watching) populateMap(animeList.Completed) populateMap(animeList.Paused) populateMap(animeList.Dropped) populateMap(animeList.Planning) populateMap(animeList.Rewatching) // Add Rewatching list return animeMap } // fuzzy matching w/ Levenshtein distance func levenshtein(a, b string) int { a = strings.ToLower(a) b = strings.ToLower(b) ar, br := []rune(a), []rune(b) alen, blen := len(ar), len(br) if alen == 0 { return blen } if blen == 0 { return alen } matrix := make([][]int, alen+1) for i := range matrix { matrix[i] = make([]int, blen+1) } for i := 0; i <= alen; i++ { matrix[i][0] = i } for j := 0; j <= blen; j++ { matrix[0][j] = j } for i := 1; i <= alen; i++ { for j := 1; j <= blen; j++ { cost := 0 if ar[i-1] != br[j-1] { cost = 1 } matrix[i][j] = min3( matrix[i-1][j]+1, matrix[i][j-1]+1, matrix[i-1][j-1]+cost, ) } } return matrix[alen][blen] } func min3(a, b, c int) int { if a < b && a < c { return a } if b < c { return b } return c } // SearchAnimeAnilist sends the query to AniList and returns a map of title to ID func SearchAnimeAnilistPreview(query, token string) (map[string]RofiSelectPreview, error) { url := "https://graphql.anilist.co" queryString := ` query ($search: String) { Page(page: 1, perPage: 50) { media(search: $search, type: ANIME) { id title { romaji english native } coverImage { large } } } }` variables := map[string]string{"search": query} requestBody, err := json.Marshal(map[string]interface{}{ "query": queryString, "variables": variables, }) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create new request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to search for anime. Status Code: %d, Response: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var responseData map[string]ResponseData err = json.Unmarshal(body, &responseData) if err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } animeList := responseData["data"].Page.Media animeDict := make(map[string]RofiSelectPreview) type scoredAnime struct { id string title string cover string score int } var scored []scoredAnime for _, anime := range animeList { idStr := strconv.Itoa(anime.ID) title := anime.Title.English if title == "" { title = anime.Title.Romaji } cover := anime.CoverImage.Large score := levenshtein(title, query) scored = append(scored, scoredAnime{idStr, title, cover, score}) } sort.Slice(scored, func(i, j int) bool { return scored[i].score < scored[j].score }) for i, s := range scored { if i >= 10 { break } animeDict[s.id] = RofiSelectPreview{ Title: s.title, CoverImage: s.cover, } } return animeDict, nil } // SearchAnimeAnilist sends the query to AniList and returns a map of title to ID func SearchAnimeAnilist(query, token string) ([]SelectionOption, error) { url := "https://graphql.anilist.co" queryString := ` query ($search: String) { Page(page: 1, perPage: 50) { media(search: $search, type: ANIME) { id title { romaji english native } } } }` variables := map[string]string{"search": query} requestBody, err := json.Marshal(map[string]interface{}{ "query": queryString, "variables": variables, }) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create new request: %w", err) } req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Content-Type", "application/json") client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to make request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("failed to search for anime. Status Code: %d, Response: %s", resp.StatusCode, string(body)) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } var responseData map[string]ResponseData err = json.Unmarshal(body, &responseData) if err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } animeList := responseData["data"].Page.Media var results []SelectionOption type scoredAnime struct { id string title string score int } var scored []scoredAnime for _, anime := range animeList { idStr := strconv.Itoa(anime.ID) title := anime.Title.English if title == "" { title = anime.Title.Romaji } score := levenshtein(title, query) scored = append(scored, scoredAnime{idStr, title, score}) } sort.Slice(scored, func(i, j int) bool { return scored[i].score < scored[j].score }) for i, s := range scored { if i >= 10 { break } results = append(results, SelectionOption{ Key: s.id, Label: s.title, }) } return results, nil } // Function to get AniList user ID and username func GetAnilistUserID(token string) (int, string, error) { url := "https://graphql.anilist.co" query := ` query { Viewer { id name } }` headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", "Accept": "application/json", } response, err := makePostRequest(url, query, nil, headers) if err != nil { return 0, "", err } data := response["data"].(map[string]interface{})["Viewer"].(map[string]interface{}) userID := int(data["id"].(float64)) userName := data["name"].(string) return userID, userName, nil } // Function to add an anime to the watching list func AddAnimeToWatchingList(animeID int, token string) error { url := "https://graphql.anilist.co" mutation := ` mutation ($mediaId: Int) { SaveMediaListEntry (mediaId: $mediaId, status: CURRENT) { id status } }` variables := map[string]interface{}{ "mediaId": animeID, } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } _, err := makePostRequest(url, mutation, variables, headers) if err != nil { return fmt.Errorf("failed to add anime: %w", err) } CurdOut(fmt.Sprintf("Anime with ID %d has been added to your watching list.", animeID)) return nil } // Function to get MAL ID using AniList media ID func GetAnimeMalID(anilistMediaID int) (int, error) { url := "https://graphql.anilist.co" query := ` query ($id: Int) { Media(id: $id) { idMal } }` variables := map[string]interface{}{ "id": anilistMediaID, } response, err := makePostRequest(url, query, variables, nil) if err != nil { return 0, err } malID := int(response["data"].(map[string]interface{})["Media"].(map[string]interface{})["idMal"].(float64)) return malID, nil } // This function retrieves the MAL ID and cover image URL for an anime from AniList func GetAnimeIDAndImage(anilistMediaID int) (int, string, error) { url := "https://graphql.anilist.co" query := ` query ($id: Int) { Media(id: $id) { coverImage { large } idMal } }` variables := map[string]interface{}{ "id": anilistMediaID, } response, err := makePostRequest(url, query, variables, nil) if err != nil { return 0, "", err } data := response["data"].(map[string]interface{})["Media"].(map[string]interface{}) malID := int(data["idMal"].(float64)) imageURL := data["coverImage"].(map[string]interface{})["large"].(string) return malID, imageURL, nil } // Function to get user data from AniList func GetUserData(token string, userID int) (map[string]interface{}, error) { query := ` query ($userId: Int, $type: MediaType) { MediaListCollection(userId: $userId, type: $type) { lists { entries { media { id episodes duration title { romaji english native } status } status score progress repeat startedAt { year month day } completedAt { year month day } } } } }` variables := map[string]interface{}{ "userId": userID, "type": "ANIME", } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } response, err := makePostRequest("https://graphql.anilist.co", query, variables, headers) if err != nil { return nil, err } return response, nil } func GetUserDataPreview(token string, userID int) (map[string]interface{}, error) { query := ` query ($userId: Int, $type: MediaType) { MediaListCollection(userId: $userId, type: $type) { lists { entries { media { id episodes duration coverImage { large } title { romaji english native } status } status score progress repeat startedAt { year month day } completedAt { year month day } } } } }` variables := map[string]interface{}{ "userId": userID, "type": "ANIME", } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } response, err := makePostRequest("https://graphql.anilist.co", query, variables, headers) if err != nil { return nil, err } return response, nil } // Function to load a JSON file func LoadJSONFile(filePath string) (map[string]interface{}, error) { data, err := os.ReadFile(filepath.Clean(filePath)) if err != nil { return nil, fmt.Errorf("failed to read file: %w", err) } var jsonData map[string]interface{} err = json.Unmarshal(data, &jsonData) if err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } return jsonData, nil } // Function to search for an anime by title in user data func SearchAnimeByTitle(jsonData map[string]interface{}, searchTitle string) []map[string]interface{} { results := []map[string]interface{}{} lists := jsonData["data"].(map[string]interface{})["MediaListCollection"].(map[string]interface{})["lists"].([]interface{}) for _, list := range lists { entries := list.(map[string]interface{})["entries"].([]interface{}) for _, entry := range entries { media := entry.(map[string]interface{})["media"].(map[string]interface{}) romajiTitle := media["title"].(map[string]interface{})["romaji"].(string) englishTitle := media["title"].(map[string]interface{})["english"].(string) episodes := int(media["episodes"].(float64)) duration := int(media["duration"].(float64)) if strings.Contains(strings.ToLower(romajiTitle), strings.ToLower(searchTitle)) || strings.Contains(strings.ToLower(englishTitle), strings.ToLower(searchTitle)) { result := map[string]interface{}{ "id": media["id"], "progress": entry.(map[string]interface{})["progress"], "romaji_title": romajiTitle, "english_title": englishTitle, "episodes": episodes, "duration": duration, } results = append(results, result) } } } return results } // Function to update anime progress func UpdateAnimeProgress(token string, mediaID, progress int) error { err := SaveAnimeListEntry(token, mediaID, nil, &progress, nil, nil, nil) if err != nil { return err } CurdOut(fmt.Sprint("Anime progress updated! Latest watched episode: ", progress)) return nil } func UpdateAnimeStatus(token string, mediaID int, status string) error { err := SaveAnimeListEntry(token, mediaID, &status, nil, nil, nil, nil) if err != nil { return fmt.Errorf("failed to update anime status: %w", err) } statusMap := map[string]string{ "CURRENT": "Currently Watching", "COMPLETED": "Completed", "PAUSED": "On Hold", "DROPPED": "Dropped", "PLANNING": "Plan to Watch", "REPEATING": "Rewatching", } CurdOut(fmt.Sprintf("Anime status updated to: %s", statusMap[status])) return nil } func SaveAnimeListEntry(token string, mediaID int, status *string, progress *int, repeat *int, startedAt *FuzzyDate, completedAt *FuzzyDate) error { url := "https://graphql.anilist.co" query := ` mutation( $mediaId: Int $status: MediaListStatus $progress: Int $repeat: Int $startedAt: FuzzyDateInput $completedAt: FuzzyDateInput ) { SaveMediaListEntry( mediaId: $mediaId status: $status progress: $progress repeat: $repeat startedAt: $startedAt completedAt: $completedAt ) { id status progress repeat startedAt { year month day } completedAt { year month day } } }` variables := map[string]interface{}{ "mediaId": mediaID, } if status != nil { variables["status"] = *status } if progress != nil { variables["progress"] = *progress } if repeat != nil { variables["repeat"] = *repeat } if startedAt != nil && (startedAt.Year != 0 || startedAt.Month != 0 || startedAt.Day != 0) { variables["startedAt"] = map[string]int{ "year": startedAt.Year, "month": startedAt.Month, "day": startedAt.Day, } } if completedAt != nil && (completedAt.Year != 0 || completedAt.Month != 0 || completedAt.Day != 0) { variables["completedAt"] = map[string]int{ "year": completedAt.Year, "month": completedAt.Month, "day": completedAt.Day, } } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } _, err := makePostRequest(url, query, variables, headers) if err != nil { return err } return nil } func CompleteAnimeRewatch(token string, anime Anime) error { status := "COMPLETED" repeat := anime.Repeat + 1 return SaveAnimeListEntry(token, anime.AnilistId, &status, nil, &repeat, &anime.StartedAt, &anime.CompletedAt) } // Function to rate an anime on AniList func RateAnime(token string, mediaID int) error { var score float64 var err error userCurdConfig := GetGlobalConfig() if userCurdConfig == nil { return fmt.Errorf("failed to get curd config") } if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Enter a score for the anime (0-10)") if err != nil { return err } score, err = strconv.ParseFloat(userInput, 64) if err != nil { return err } } else { fmt.Println("Rate this anime: ") fmt.Scanln(&score) } url := "https://graphql.anilist.co" query := ` mutation($mediaId: Int, $score: Float) { SaveMediaListEntry(mediaId: $mediaId, score: $score) { id mediaId score } }` variables := map[string]interface{}{ "mediaId": mediaID, "score": score, } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } _, err = makePostRequest(url, query, variables, headers) if err != nil { return err } CurdOut(fmt.Sprintf("Successfully rated anime (mediaId: %d) with score: %.2f", mediaID, score)) return nil } // Helper function to make POST requests func makePostRequest(url, query string, variables map[string]interface{}, headers map[string]string) (map[string]interface{}, error) { requestBody, err := json.Marshal(map[string]interface{}{ "query": query, "variables": variables, }) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := http.NewRequest("POST", url, bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") // <-- Important! for key, value := range headers { req.Header.Set(key, value) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed with status %d: %s", resp.StatusCode, body) } var responseData map[string]interface{} // Unmarshal the response into a map err = json.Unmarshal(body, &responseData) if err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } return responseData, nil } func ParseAnimeList(input map[string]interface{}) AnimeList { var animeList AnimeList toInt := func(value interface{}) int { switch v := value.(type) { case int: return v case float64: return int(v) // You could also use int(math.Round(v)) to round default: return 0 // Default value for unexpected types } } safeString := func(value interface{}) string { if value == nil { return "" } // Attempt to assert the value as a string if str, ok := value.(string); ok { return str } // If it's not a string, return an empty string or handle it as needed return "" } parseFuzzyDate := func(value interface{}) FuzzyDate { dateMap, ok := value.(map[string]interface{}) if !ok || dateMap == nil { return FuzzyDate{} } return FuzzyDate{ Year: toInt(dateMap["year"]), Month: toInt(dateMap["month"]), Day: toInt(dateMap["day"]), } } // Access the list entries in the input map if input["data"] == nil { Log("Anilist request failed") CurdOut("Anilist request failed") ExitCurd(fmt.Errorf("Anilist request failed")) return animeList } data := input["data"].(map[string]interface{}) mediaList := data["MediaListCollection"].(map[string]interface{})["lists"].([]interface{}) for _, list := range mediaList { entries := list.(map[string]interface{})["entries"].([]interface{}) for _, entry := range entries { entryData := entry.(map[string]interface{}) media := entryData["media"].(map[string]interface{}) animeEntry := Entry{ Media: Media{ Duration: toInt(media["duration"]), Episodes: toInt(media["episodes"]), ID: toInt(media["id"]), Title: AnimeTitle{ English: safeString(media["title"].(map[string]interface{})["english"]), Romaji: safeString(media["title"].(map[string]interface{})["romaji"]), Japanese: safeString(media["title"].(map[string]interface{})["native"]), }, Status: safeString(media["status"]), }, Progress: toInt(entryData["progress"]), Repeat: toInt(entryData["repeat"]), Score: entryData["score"].(float64), Status: safeString(entryData["status"]), // Ensure status is fetched safely StartedAt: parseFuzzyDate(entryData["startedAt"]), CompletedAt: parseFuzzyDate(entryData["completedAt"]), } if coverImage, ok := media["coverImage"].(map[string]interface{}); ok { animeEntry.CoverImage = safeString(coverImage["large"]) } // Append entries based on their status switch animeEntry.Status { case "CURRENT": animeList.Watching = append(animeList.Watching, animeEntry) case "COMPLETED": animeList.Completed = append(animeList.Completed, animeEntry) case "PAUSED": animeList.Paused = append(animeList.Paused, animeEntry) case "DROPPED": animeList.Dropped = append(animeList.Dropped, animeEntry) case "PLANNING": animeList.Planning = append(animeList.Planning, animeEntry) case "REPEATING": // Anilist uses REPEATING for rewatching animeList.Rewatching = append(animeList.Rewatching, animeEntry) } } } return animeList } // FindAnimeByID searches for an anime by its ID in the AnimeList func FindAnimeByAnilistID(list AnimeList, idStr string) (*Entry, error) { id, err := strconv.Atoi(idStr) if err != nil { return nil, fmt.Errorf("invalid ID format: %s", idStr) } // Define a slice of pointers to hold categories categories := [][]Entry{ list.Watching, list.Completed, list.Paused, list.Dropped, list.Planning, list.Rewatching, // Add Rewatching list } // Iterate through each category for _, category := range categories { for _, entry := range category { if entry.Media.ID == id { return &entry, nil // Return a pointer to the found entry } } } return nil, fmt.Errorf("anime with ID %d not found", id) // Return an error if not found } // FindAnimeByAnilistIDInAnimes searches for an anime by its AniList ID in a slice of Anime func FindAnimeByAnilistIDInAnimes(animes []Anime, anilistID int) (*Anime, error) { for i := range animes { if animes[i].AnilistId == anilistID { return &animes[i], nil } } return nil, fmt.Errorf("anime with ID %d not found", anilistID) } // GetAnimeDataByID retrieves detailed anime data from AniList using the anime's ID and user token func GetAnimeDataByID(id int, token string) (Anime, error) { url := "https://graphql.anilist.co" query := ` query ($id: Int) { Media(id: $id, type: ANIME) { id episodes status nextAiringEpisode { episode } } }` variables := map[string]interface{}{ "id": id, } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } response, err := makePostRequest(url, query, variables, headers) if err != nil { return Anime{}, fmt.Errorf("failed to get anime data: %w", err) } data, ok := response["data"].(map[string]interface{}) if !ok { return Anime{}, fmt.Errorf("invalid response format: data field missing") } media, ok := data["Media"].(map[string]interface{}) if !ok { return Anime{}, fmt.Errorf("invalid response format: Media field missing") } anime := Anime{ AnilistId: id, IsAiring: false, } // Safely handle episodes field which might be nil for currently airing shows if episodes, ok := media["episodes"].(float64); ok { anime.TotalEpisodes = int(episodes) } // Check status if status, ok := media["status"].(string); ok { anime.IsAiring = status == "RELEASING" } // Double check with nextAiringEpisode if nextEp, ok := media["nextAiringEpisode"].(map[string]interface{}); ok && nextEp != nil { anime.IsAiring = true } return anime, nil } // SequelInfo holds information about a sequel anime type SequelInfo struct { ID int Title AnimeTitle CoverImage string Episodes int Status string // "FINISHED", "RELEASING", "NOT_YET_RELEASED" SiteURL string } // GetAnimeSequel fetches sequel information for a given anime from AniList // GetAnimeSequel fetches sequel information for a given anime from AniList func GetAnimeSequel(animeID int, token string) ([]SequelInfo, error) { url := "https://graphql.anilist.co" query := ` query ($id: Int) { Media(id: $id, type: ANIME) { relations { edges { relationType node { id title { romaji english } coverImage { large } episodes status siteUrl } } } } }` variables := map[string]interface{}{ "id": animeID, } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } response, err := makePostRequest(url, query, variables, headers) if err != nil { return nil, fmt.Errorf("failed to get anime relations: %w", err) } data, ok := response["data"].(map[string]interface{}) if !ok { return nil, fmt.Errorf("invalid response format: data field missing") } media, ok := data["Media"].(map[string]interface{}) if !ok { return nil, fmt.Errorf("invalid response format: Media field missing") } relations, ok := media["relations"].(map[string]interface{}) if !ok { return nil, nil // No relations found } edges, ok := relations["edges"].([]interface{}) if !ok || len(edges) == 0 { return nil, nil // No edges found } var sequels []SequelInfo // Look for a SEQUEL relation for _, edge := range edges { edgeData, ok := edge.(map[string]interface{}) if !ok { continue } relationType, ok := edgeData["relationType"].(string) if !ok || relationType != "SEQUEL" { continue } node, ok := edgeData["node"].(map[string]interface{}) if !ok { continue } var sequel SequelInfo // Parse ID if id, ok := node["id"].(float64); ok { sequel.ID = int(id) } // Parse title if title, ok := node["title"].(map[string]interface{}); ok { if romaji, ok := title["romaji"].(string); ok { sequel.Title.Romaji = romaji } if english, ok := title["english"].(string); ok { sequel.Title.English = english } } // Parse cover image if coverImage, ok := node["coverImage"].(map[string]interface{}); ok { if large, ok := coverImage["large"].(string); ok { sequel.CoverImage = large } } // Parse episodes if episodes, ok := node["episodes"].(float64); ok { sequel.Episodes = int(episodes) } // Parse status if status, ok := node["status"].(string); ok { sequel.Status = status } // Parse siteUrl if siteUrl, ok := node["siteUrl"].(string); ok { sequel.SiteURL = siteUrl } sequels = append(sequels, sequel) } if len(sequels) == 0 { return nil, nil // No sequel found } return sequels, nil } // AddAnimeToList adds an anime to a specified list (CURRENT, PLANNING, PAUSED, DROPPED) func AddAnimeToList(animeID int, status string, token string) error { url := "https://graphql.anilist.co" mutation := ` mutation ($mediaId: Int, $status: MediaListStatus) { SaveMediaListEntry (mediaId: $mediaId, status: $status) { id status } }` variables := map[string]interface{}{ "mediaId": animeID, "status": status, } headers := map[string]string{ "Authorization": "Bearer " + token, "Content-Type": "application/json", } _, err := makePostRequest(url, mutation, variables, headers) if err != nil { return fmt.Errorf("failed to add anime to list: %w", err) } statusMap := map[string]string{ "CURRENT": "Currently Watching", "COMPLETED": "Completed", "PAUSED": "On Hold", "DROPPED": "Dropped", "PLANNING": "Plan to Watch", "REPEATING": "Rewatching", } CurdOut(fmt.Sprintf("Anime added to: %s", statusMap[status])) return nil } // FindSequelInAnimeList searches for a sequel in the user's anime list and returns its status func FindSequelInAnimeList(list AnimeList, sequelID int) (string, bool) { // Check all categories for _, entry := range list.Watching { if entry.Media.ID == sequelID { return "CURRENT", true } } for _, entry := range list.Planning { if entry.Media.ID == sequelID { return "PLANNING", true } } for _, entry := range list.Completed { if entry.Media.ID == sequelID { return "COMPLETED", true } } for _, entry := range list.Paused { if entry.Media.ID == sequelID { return "PAUSED", true } } for _, entry := range list.Dropped { if entry.Media.ID == sequelID { return "DROPPED", true } } for _, entry := range list.Rewatching { if entry.Media.ID == sequelID { return "REWATCHING", true } } return "", false } ================================================ FILE: internal/anilist_cache.go ================================================ package internal import ( "encoding/json" "fmt" "os" "path/filepath" "strconv" "sync" "time" ) const animeListCacheFileName = "anilist_list_cache.json" type animeListCachePayload struct { AnimeList AnimeList `json:"anime_list"` UpdatedAt time.Time `json:"updated_at"` UserID int `json:"user_id"` } type AnimeListSync struct { mu sync.RWMutex current AnimeList updates chan AnimeList refreshDone chan struct{} // closed exactly once when the background refresh finishes closeOnce sync.Once } func NewAnimeListSync(initial AnimeList) *AnimeListSync { return &AnimeListSync{ current: initial, updates: make(chan AnimeList, 1), refreshDone: make(chan struct{}), } } // MarkRefreshDone closes the refreshDone channel exactly once. func (s *AnimeListSync) MarkRefreshDone() { s.closeOnce.Do(func() { close(s.refreshDone) }) } // RefreshDone returns a channel that is closed when the background refresh finishes. func (s *AnimeListSync) RefreshDone() <-chan struct{} { return s.refreshDone } func (s *AnimeListSync) Current() AnimeList { s.mu.RLock() defer s.mu.RUnlock() return s.current } func (s *AnimeListSync) Replace(list AnimeList, notify bool) bool { s.mu.Lock() changed := !animeListEqual(s.current, list) s.current = list s.mu.Unlock() if changed && notify { select { case s.updates <- list: default: select { case <-s.updates: default: } s.updates <- list } } return changed } func (s *AnimeListSync) Updates() <-chan AnimeList { return s.updates } func animeListCachePath(storagePath string) string { return filepath.Join(os.ExpandEnv(storagePath), animeListCacheFileName) } func loadAnimeListCache(storagePath string, userID int) (animeListCachePayload, error) { cacheFilePath := animeListCachePath(storagePath) data, err := os.ReadFile(cacheFilePath) if err != nil { return animeListCachePayload{}, err } var payload animeListCachePayload if err := json.Unmarshal(data, &payload); err != nil { return animeListCachePayload{}, fmt.Errorf("failed to parse anime list cache: %w", err) } if payload.UserID != 0 && userID != 0 && payload.UserID != userID { return animeListCachePayload{}, fmt.Errorf("anime list cache belongs to a different AniList user") } return payload, nil } func saveAnimeListCache(storagePath string, userID int, list AnimeList) error { storagePath = os.ExpandEnv(storagePath) if err := os.MkdirAll(storagePath, 0o755); err != nil { return fmt.Errorf("failed to create storage directory: %w", err) } payload := animeListCachePayload{ AnimeList: list, UpdatedAt: time.Now(), UserID: userID, } data, err := json.MarshalIndent(payload, "", " ") if err != nil { return fmt.Errorf("failed to marshal anime list cache: %w", err) } cacheFilePath := animeListCachePath(storagePath) tempFilePath := cacheFilePath + ".tmp" if err := os.WriteFile(tempFilePath, data, 0o644); err != nil { return fmt.Errorf("failed to write anime list cache: %w", err) } if err := os.Rename(tempFilePath, cacheFilePath); err != nil { _ = os.Remove(tempFilePath) return fmt.Errorf("failed to replace anime list cache: %w", err) } return nil } func animeListEqual(a, b AnimeList) bool { left, err := json.Marshal(a) if err != nil { return false } right, err := json.Marshal(b) if err != nil { return false } return string(left) == string(right) } func FetchLatestAnimeList(token string, userID int) (AnimeList, error) { userData, err := GetUserDataPreview(token, userID) if err != nil { return AnimeList{}, err } return ParseAnimeList(userData), nil } func refreshAnimeListInBackground(userCurdConfig *CurdConfig, user *User) { if user == nil || user.ListSync == nil { return } go func() { // Signal done regardless of success/failure so callers never block forever. defer user.ListSync.MarkRefreshDone() // Only fetch the user ID when we don't already have it (first run, no cache). // On cache hits user.Id is already seeded, so skip this extra round-trip. if user.Id == 0 { userID, username, err := GetAnilistUserID(user.Token) if err != nil { Log(fmt.Sprintf("Failed to refresh user ID in background: %v", err)) return } user.Id = userID if user.Username == "" { user.Username = username } } latestList, err := FetchLatestAnimeList(user.Token, user.Id) if err != nil { Log(fmt.Sprintf("Failed to refresh anime list in background: %v", err)) return } if err := saveAnimeListCache(userCurdConfig.StoragePath, user.Id, latestList); err != nil { Log(fmt.Sprintf("Failed to save refreshed anime list cache: %v", err)) } user.ListSync.Replace(latestList, true) }() } func InitializeUserAnimeList(userCurdConfig *CurdConfig, user *User) error { cachedPayload, err := loadAnimeListCache(userCurdConfig.StoragePath, user.Id) if err == nil { // Seed user ID from cache so we skip the blocking GetAnilistUserID network call. if user.Id == 0 && cachedPayload.UserID != 0 { user.Id = cachedPayload.UserID } user.AnimeList = cachedPayload.AnimeList user.ListSync = NewAnimeListSync(cachedPayload.AnimeList) // Refresh user ID + anime list in the background (non-blocking). refreshAnimeListInBackground(userCurdConfig, user) return nil } if !os.IsNotExist(err) { Log(fmt.Sprintf("Failed to load anime list cache, fetching latest instead: %v", err)) } // No cache — blocking fetch is unavoidable on first run. if user.Id == 0 { userID, username, idErr := GetAnilistUserID(user.Token) if idErr != nil { return idErr } user.Id = userID user.Username = username } latestList, err := FetchLatestAnimeList(user.Token, user.Id) if err != nil { return err } user.AnimeList = latestList user.ListSync = NewAnimeListSync(latestList) // Blocking fetch already has the freshest data — mark done immediately. user.ListSync.MarkRefreshDone() if err := saveAnimeListCache(userCurdConfig.StoragePath, user.Id, latestList); err != nil { Log(fmt.Sprintf("Failed to save anime list cache: %v", err)) } return nil } func RefreshUserAnimeList(userCurdConfig *CurdConfig, user *User) error { if user.Id == 0 { userID, username, err := GetAnilistUserID(user.Token) if err != nil { return err } user.Id = userID if user.Username == "" { user.Username = username } } latestList, err := FetchLatestAnimeList(user.Token, user.Id) if err != nil { return err } user.AnimeList = latestList if user.ListSync == nil { user.ListSync = NewAnimeListSync(latestList) } else { user.ListSync.Replace(latestList, true) } if err := saveAnimeListCache(userCurdConfig.StoragePath, user.Id, latestList); err != nil { Log(fmt.Sprintf("Failed to save anime list cache: %v", err)) } return nil } func buildCategorySelectionOptions(list AnimeList, category string) []SelectionOption { userCurdConfig := GetGlobalConfig() options := make([]SelectionOption, 0) for _, entry := range getEntriesByCategory(list, category) { title := entry.Media.Title.English if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { title = entry.Media.Title.Romaji } options = append(options, SelectionOption{ Key: strconv.Itoa(entry.Media.ID), Label: title, }) } return options } func buildCategoryPreviewOptions(list AnimeList, category string) map[string]RofiSelectPreview { userCurdConfig := GetGlobalConfig() options := make(map[string]RofiSelectPreview) for _, entry := range getEntriesByCategory(list, category) { title := entry.Media.Title.English if title == "" || userCurdConfig.AnimeNameLanguage == "romaji" { title = entry.Media.Title.Romaji } options[strconv.Itoa(entry.Media.ID)] = RofiSelectPreview{ Title: title, CoverImage: entry.CoverImage, } } return options } ================================================ FILE: internal/anime_list.go ================================================ package internal import ( "bytes" "encoding/json" "fmt" "io" "net/http" "path/filepath" "strings" ) type anime struct { ID string `json:"_id"` Name string `json:"name"` EnglishName string `json:"englishName"` Thumbnail string `json:"thumbnail"` AvailableEpisodes interface{} `json:"availableEpisodes"` } type response struct { Data struct { Shows struct { Edges []anime `json:"edges"` } `json:"shows"` } `json:"data"` } func normalizeTranslationType(mode string) string { if strings.EqualFold(strings.TrimSpace(mode), "dub") { return "dub" } return "sub" } func alternateTranslationType(mode string) string { if normalizeTranslationType(mode) == "dub" { return "sub" } return "dub" } // func main() { // // Get environment variables // mode := "sub" // // Query for the anime (from a file in this example) // query := "one piece" // // Search anime // animeList, err := SearchAnime(string(query), mode) // if err != nil { // } // fmt.Println(animeList) // } func searchAllAnime(query, mode string) ([]SelectionOption, error) { preferredMode := normalizeTranslationType(mode) alternateMode := alternateTranslationType(preferredMode) preferredResults, preferredErr := searchAnimeByMode(query, preferredMode, preferredMode) alternateResults, alternateErr := searchAnimeByMode(query, alternateMode, preferredMode) if preferredErr != nil { Log(fmt.Sprintf("Failed searching %s results for %q: %v", preferredMode, query, preferredErr)) } if alternateErr != nil { Log(fmt.Sprintf("Failed searching %s results for %q: %v", alternateMode, query, alternateErr)) } if preferredErr != nil && alternateErr != nil { return nil, preferredErr } animeList := make([]SelectionOption, 0, len(preferredResults)+len(alternateResults)) seen := make(map[string]struct{}, len(preferredResults)+len(alternateResults)) for _, option := range preferredResults { animeList = append(animeList, option) seen[option.Key] = struct{}{} } for _, option := range alternateResults { if _, exists := seen[option.Key]; exists { continue } animeList = append(animeList, option) } return animeList, nil } func searchAnimeByMode(query, mode, preferredMode string) ([]SelectionOption, error) { userCurdConfig := GetGlobalConfig() logFile = filepath.Join(GetStoragePath(), "debug.log") const ( agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" allanimeRef = "https://allanime.to" allanimeBase = "allanime.day" allanimeAPI = "https://api." + allanimeBase + "/api" ) mode = normalizeTranslationType(mode) preferredMode = normalizeTranslationType(preferredMode) animeList := make([]SelectionOption, 0) searchGql := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { edges { _id name englishName thumbnail availableEpisodes __typename } } }` // Prepare the GraphQL variables variables := map[string]interface{}{ "search": map[string]interface{}{ "allowAdult": false, "allowUnknown": false, "query": query, }, "limit": 40, "page": 1, "translationType": mode, "countryOrigin": "ALL", } // Build POST request body requestBody, err := json.Marshal(map[string]interface{}{ "query": searchGql, "variables": variables, }) if err != nil { Log(fmt.Sprintf("Error encoding request body to JSON: %v", err)) return animeList, err } // Make the HTTP POST request req, err := http.NewRequest("POST", allanimeAPI, bytes.NewBuffer(requestBody)) if err != nil { Log(fmt.Sprintf("Error creating HTTP request: %v", err)) return animeList, err } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", agent) req.Header.Set("Referer", allanimeRef) req.Header.Set("Origin", allanimeRef) resp, err := sharedHTTPClient.Do(req) if err != nil { Log(fmt.Sprintf("Error making HTTP request: %v", err)) return animeList, err } defer resp.Body.Close() // Read the response body body, err := io.ReadAll(resp.Body) if err != nil { Log(fmt.Sprintf("Error reading response body: %v", err)) return animeList, err } // Debug: Log the response status and first part of the body Log(fmt.Sprintf("Response Status: %s", resp.Status)) Log(fmt.Sprintf("Response Body (first 500 chars): %s", string(body[:min(len(body), 500)]))) // Parse the JSON response var response response err = json.Unmarshal(body, &response) if err != nil { Log(fmt.Sprintf("Error parsing JSON for query '%s': %v\nBody: %s", query, err, string(body))) return animeList, err } for _, anime := range response.Data.Shows.Edges { var episodesStr string if episodes, ok := anime.AvailableEpisodes.(map[string]interface{}); ok { if modeEpisodes, ok := episodes[mode].(float64); ok { episodesStr = fmt.Sprintf("%d", int(modeEpisodes)) } else { episodesStr = "Unknown" } } else { episodesStr = "Unknown" } // Use English name if available and configured, otherwise use default name displayName := anime.Name if anime.EnglishName != "" && userCurdConfig != nil && userCurdConfig.AnimeNameLanguage == "english" { displayName = anime.EnglishName } label := fmt.Sprintf("%s (%s episodes)", displayName, episodesStr) if mode != preferredMode { label = fmt.Sprintf("%s [%s]", label, mode) } animeList = append(animeList, SelectionOption{ Title: displayName, Key: anime.ID, Label: label, Thumbnail: anime.Thumbnail, }) } return animeList, nil } // Helper function func min(a, b int) int { if a < b { return a } return b } ================================================ FILE: internal/aniskip.go ================================================ package internal import ( "encoding/json" "fmt" "io" "math" "net/http" ) // skipTimesResponse struct to hold the response from the AniSkip API type skipTimesResponse struct { Found bool `json:"found"` Results []skipResult `json:"results"` } // skipResult struct to hold individual skip result data type skipResult struct { Interval skipInterval `json:"interval"` } // skipInterval struct to hold the start and end times for skip intervals type skipInterval struct { StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` } // GetAniSkipData fetches skip times data for a given anime ID and episode func GetAniSkipData(animeMalId int, episode int) (string, error) { baseURL := "https://api.aniskip.com/v1/skip-times" url := fmt.Sprintf("%s/%d/%d?types=op&types=ed", baseURL, animeMalId, episode) resp, err := http.Get(url) if err != nil { Log(fmt.Errorf("error fetching data from AniSkip API: %w", err)) return "", fmt.Errorf("error fetching data from AniSkip API: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { Log(fmt.Sprintf("failed with status %d", resp.StatusCode)) return "", fmt.Errorf("failed with status %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { Log(fmt.Errorf("failed to read response body %w", err)) return "", fmt.Errorf("failed to read response body %w", err) } return string(body), nil } // RoundTime rounds a time value to the specified precision func RoundTime(timeValue float64, precision int) float64 { multiplier := math.Pow(10, float64(precision)) return math.Floor(timeValue*multiplier+0.5) / multiplier } // ParseAniSkipResponse parses the response text from the AniSkip API and updates the Anime struct func ParseAniSkipResponse(responseText string, anime *Anime, timePrecision int) error { if responseText == "" { return fmt.Errorf("response text is empty") } var data skipTimesResponse err := json.Unmarshal([]byte(responseText), &data) if err != nil { return fmt.Errorf("error unmarshalling response: %w", err) } if !data.Found { return fmt.Errorf("no skip times found") } // Populate skip times for the anime's episode if len(data.Results) > 0 { op := data.Results[0].Interval anime.Ep.SkipTimes.Op = Skip{ Start: int(RoundTime(op.StartTime, timePrecision)), End: int(RoundTime(op.EndTime, timePrecision)), } } if len(data.Results) > 1 { ed := data.Results[len(data.Results)-1].Interval anime.Ep.SkipTimes.Ed = Skip{ Start: int(RoundTime(ed.StartTime, timePrecision)), End: int(RoundTime(ed.EndTime, timePrecision)), } } return nil } // GetAndParseAniSkipData fetches and parses skip times for a given anime ID and episode func GetAndParseAniSkipData(animeMalId int, episode int, timePrecision int, anime *Anime) error { responseText, err := GetAniSkipData(animeMalId, episode) if err != nil { return err } return ParseAniSkipResponse(responseText, anime, timePrecision) } // Function to send OP and ED timings to MPV func SendSkipTimesToMPV(anime *Anime) error { chapterList := []map[string]interface{}{ { "title": "Pre-Opening", "time": 0.0, "end": float64(anime.Ep.SkipTimes.Op.Start), }, { "title": "Opening", "time": float64(anime.Ep.SkipTimes.Op.Start), "end": float64(anime.Ep.SkipTimes.Op.End), }, { "title": "Main", "time": float64(anime.Ep.SkipTimes.Op.End), "end": float64(anime.Ep.SkipTimes.Ed.Start), }, { "title": "Ending", "time": float64(anime.Ep.SkipTimes.Ed.Start), "end": float64(anime.Ep.SkipTimes.Ed.End), }, { "title": "Post-Credits", "time": float64(anime.Ep.SkipTimes.Ed.End), }, } _, err := MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{ "set_property", "chapter-list", chapterList, }) if err != nil { return fmt.Errorf("error sending command to MPV: %w", err) } return nil } ================================================ FILE: internal/config.go ================================================ package internal import ( "bufio" "context" "encoding/json" "fmt" // "io" "net/http" "net/url" "os" "path/filepath" "reflect" "strconv" "strings" "time" "github.com/pkg/browser" ) const ( anilistOAuthURL = "https://anilist.co/api/v2/oauth" anilistClientID = "20686" anilistClientSecret = "APfx41cOgSQVMvi88v7PbN7g6kzed2ZQRcxmACod" anilistRedirectURI = "http://localhost:8000/oauth/callback" anilistServerPort = 8000 ) // AnilistToken represents the OAuth token response from Anilist type AnilistToken struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` RefreshToken string `json:"refresh_token"` ExpiresAt time.Time `json:"expires_at"` } // CurdConfig struct with field names that match the config keys type CurdConfig struct { Player string `config:"Player"` MpvArgs []string `config:"MpvArgs"` SubsLanguage string `config:"SubsLanguage"` SubOrDub string `config:"SubOrDub"` StoragePath string `config:"StoragePath"` AnimeNameLanguage string `config:"AnimeNameLanguage"` MenuOrder string `config:"MenuOrder"` PercentageToMarkComplete int `config:"PercentageToMarkComplete"` NextEpisodePrompt bool `config:"NextEpisodePrompt"` SkipOp bool `config:"SkipOp"` SkipEd bool `config:"SkipEd"` SkipFiller bool `config:"SkipFiller"` ImagePreview bool `config:"ImagePreview"` SkipRecap bool `config:"SkipRecap"` RofiSelection bool `config:"RofiSelection"` CurrentCategory bool `config:"CurrentCategory"` ScoreOnCompletion bool `config:"ScoreOnCompletion"` SaveMpvSpeed bool `config:"SaveMpvSpeed"` AddMissingOptions bool `config:"AddMissingOptions"` AlternateScreen bool `config:"AlternateScreen"` DiscordPresence bool `config:"DiscordPresence"` DiscordClientId string `config:"DiscordClientId"` Provider string `config:"Provider"` } func GetStoragePath() string { if globalConfig != nil && globalConfig.StoragePath != "" { return os.ExpandEnv(globalConfig.StoragePath) } return filepath.Join(os.ExpandEnv("$HOME"), ".local", "share", "curd") } // Default configuration values as a map func defaultConfigMap() map[string]string { return map[string]string{ "Player": "mpv", "MpvArgs": "[]", "StoragePath": "$HOME/.local/share/curd", "AnimeNameLanguage": "english", "SubsLanguage": "english", "MenuOrder": "CURRENT,ALL,UNTRACKED,UPDATE,CONTINUE_LAST,PROVIDER", "SubOrDub": "sub", "PercentageToMarkComplete": "85", "NextEpisodePrompt": "false", "SkipOp": "true", "SkipEd": "true", "SkipFiller": "true", "SkipRecap": "true", "RofiSelection": "false", "ImagePreview": "false", "ScoreOnCompletion": "true", "SaveMpvSpeed": "true", "AddMissingOptions": "true", "AlternateScreen": "true", "DiscordPresence": "true", "DiscordClientId": "1287457464148820089", "Provider": "allanime", } } var globalConfig *CurdConfig func SetGlobalConfig(config *CurdConfig) { globalConfig = config } func GetGlobalConfig() *CurdConfig { return globalConfig } // Helper function to parse string array from config func parseStringArray(value string) []string { // Remove brackets and split by comma value = strings.TrimPrefix(value, "[") value = strings.TrimSuffix(value, "]") if value == "" { return nil } // Split by comma and trim spaces and quotes from each element parts := strings.Split(value, ",") result := make([]string, 0, len(parts)) for _, part := range parts { // Trim spaces and quotes part = strings.TrimSpace(part) part = strings.Trim(part, "\"") if part != "" { result = append(result, part) } } return result } var GlobalConfigPath string // LoadConfig reads or creates the config file, adds missing fields, and returns the populated CurdConfig struct func LoadConfig(configPath string) (CurdConfig, error) { configPath = os.ExpandEnv(configPath) // Substitute environment variables like $HOME GlobalConfigPath = configPath // Check if config file exists if _, err := os.Stat(configPath); os.IsNotExist(err) { // Create the config file with default values if it doesn't exist CurdOut("Config file not found. Creating default config...") if err := createDefaultConfig(configPath); err != nil { return CurdConfig{}, fmt.Errorf("error creating default config file: %v", err) } } // Load the config from file configMap, err := LoadConfigFromFile(configPath) if err != nil { return CurdConfig{}, fmt.Errorf("error loading config file: %v", err) } // Check AddMissingOptions setting first addMissing := true if val, exists := configMap["AddMissingOptions"]; exists { addMissing, _ = strconv.ParseBool(val) } // Add missing fields to the config map updated := false defaultConfigMap := defaultConfigMap() for key, defaultValue := range defaultConfigMap { if _, exists := configMap[key]; !exists { configMap[key] = defaultValue updated = true } } // Write updated config back to file only if AddMissingOptions is true if addMissing && updated { if err := SaveConfigToFile(configPath, configMap); err != nil { return CurdConfig{}, fmt.Errorf("error saving updated config file: %v", err) } } // Parse string arrays if mpvArgs, exists := configMap["MpvArgs"]; exists { configMap["MpvArgs"] = mpvArgs } // Populate the CurdConfig struct from the config map config := PopulateConfig(configMap) return config, nil } // Create a config file with default values in key=value format // Ensure the directory exists before creating the file func createDefaultConfig(path string) error { defaultConfig := defaultConfigMap() // Ensure the directory exists dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("error creating directory: %v", err) } file, err := os.Create(path) if err != nil { return fmt.Errorf("error creating file: %v", err) } defer file.Close() writer := bufio.NewWriter(file) for key, value := range defaultConfig { line := fmt.Sprintf("%s=%s\n", key, value) if _, err := writer.WriteString(line); err != nil { return fmt.Errorf("error writing to file: %v", err) } } if err := writer.Flush(); err != nil { return fmt.Errorf("error flushing writer: %v", err) } return nil } // authenticateWithBrowser performs OAuth authentication using browser func authenticateWithBrowser(tokenPath string) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() // Try to load existing token first if token, err := loadToken(tokenPath); err == nil && isTokenValid(token) { return token.AccessToken, nil } // Start local server to handle OAuth callback callbackCh := make(chan string, 1) errCh := make(chan error, 1) mux := http.NewServeMux() srv := &http.Server{ Addr: fmt.Sprintf(":%d", anilistServerPort), Handler: mux, } // Handle OAuth callback - for authorization code grant, code comes in query params mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) { code := r.URL.Query().Get("code") errorParam := r.URL.Query().Get("error") w.Header().Set("Content-Type", "text/html") if errorParam != "" { w.WriteHeader(http.StatusBadRequest) html := fmt.Sprintf(` Curd Authentication
Authentication failed: %s

You can close this window and try again.

`, errorParam) fmt.Fprint(w, html) errCh <- fmt.Errorf("oauth error: %s", errorParam) return } if code == "" { w.WriteHeader(http.StatusBadRequest) html := ` Curd Authentication
No authorization code received

You can close this window and try again.

` fmt.Fprint(w, html) errCh <- fmt.Errorf("no authorization code received") return } // Exchange authorization code for access token go func() { tokenURL := fmt.Sprintf("%s/token", anilistOAuthURL) data := url.Values{ "grant_type": {"authorization_code"}, "client_id": {anilistClientID}, "client_secret": {anilistClientSecret}, "redirect_uri": {anilistRedirectURI}, "code": {code}, } resp, err := http.PostForm(tokenURL, data) if err != nil { errCh <- fmt.Errorf("failed to exchange code for token: %w", err) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { errCh <- fmt.Errorf("token exchange failed with status: %d", resp.StatusCode) return } var tokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil { errCh <- fmt.Errorf("failed to parse token response: %w", err) return } if tokenResponse.AccessToken == "" { errCh <- fmt.Errorf("no access token in response") return } callbackCh <- tokenResponse.AccessToken }() // Show success page immediately html := ` Curd Authentication
Processing authentication...

Exchanging authorization code for token. You can close this window.

` fmt.Fprint(w, html) }) // Start server in background go func() { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { errCh <- fmt.Errorf("failed to start server: %w", err) } }() defer srv.Shutdown(ctx) // Give server a moment to start time.Sleep(100 * time.Millisecond) // Open browser for authentication using Authorization Code Grant flow (response_type=code) authURL := fmt.Sprintf("%s/authorize?client_id=%s&redirect_uri=%s&response_type=code", anilistOAuthURL, anilistClientID, url.QueryEscape(anilistRedirectURI)) fmt.Println("Opening browser for AniList 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 accessToken string select { case accessToken = <-callbackCh: case err := <-errCh: return "", fmt.Errorf("authentication failed: %w", err) case <-ctx.Done(): return "", fmt.Errorf("authentication timeout after 5 minutes") } // Create token object and save token := &AnilistToken{ AccessToken: accessToken, TokenType: "Bearer", ExpiresIn: 31536000, // AniList tokens are valid for 1 year ExpiresAt: time.Now().Add(365 * 24 * time.Hour), } // Save token to file if err := saveToken(tokenPath, token); err != nil { return "", fmt.Errorf("failed to save token: %w", err) } fmt.Println("Authentication successful!") return token.AccessToken, nil } // loadToken loads the token from the token file func loadToken(tokenPath string) (*AnilistToken, error) { data, err := os.ReadFile(tokenPath) if err != nil { return nil, fmt.Errorf("failed to read token file: %w", err) } var token AnilistToken if err := json.Unmarshal(data, &token); err != nil { return nil, fmt.Errorf("failed to parse token file: %w", err) } return &token, nil } // saveToken saves the token to the token file func saveToken(tokenPath string, token *AnilistToken) error { // Ensure directory exists if err := os.MkdirAll(filepath.Dir(tokenPath), 0755); err != nil { return fmt.Errorf("failed to create directory: %w", err) } data, err := json.Marshal(token) if err != nil { return fmt.Errorf("failed to marshal token: %w", err) } return os.WriteFile(tokenPath, data, 0600) } // isTokenValid checks if the token is still valid func isTokenValid(token *AnilistToken) bool { return token != nil && token.AccessToken != "" && time.Now().Before(token.ExpiresAt) } // GetTokenFromFile loads the token from the token file (supports both old text format and new JSON format) func GetTokenFromFile(tokenPath string) (string, error) { data, err := os.ReadFile(tokenPath) if err != nil { 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 } return "", fmt.Errorf("token has expired") } // Fall back to plain text format (old format) plainToken := strings.TrimSpace(string(data)) if plainToken == "" { return "", fmt.Errorf("empty token file") } return plainToken, nil } func ChangeToken(config *CurdConfig, user *User) { var err error 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...") // 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)) } } if user.Token == "" { ExitCurd(fmt.Errorf("no token provided")) } fmt.Println("Token saved successfully!") } // LoadConfigFromFile loads config file from disk into a map (key=value format) func LoadConfigFromFile(path string) (map[string]string, error) { file, err := os.Open(path) if err != nil { return nil, err } defer file.Close() configMap := make(map[string]string) scanner := bufio.NewScanner(file) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue // Skip empty lines and comments } parts := strings.SplitN(line, "=", 2) if len(parts) == 2 { key := strings.TrimSpace(parts[0]) value := strings.TrimSpace(parts[1]) configMap[key] = value } } if err := scanner.Err(); err != nil { return nil, err } return configMap, nil } // SaveConfigToFile saves updated config map to file in key=value format func SaveConfigToFile(path string, configMap map[string]string) error { file, err := os.Create(path) if err != nil { return err } defer file.Close() writer := bufio.NewWriter(file) for key, value := range configMap { line := fmt.Sprintf("%s=%s\n", key, value) if _, err := writer.WriteString(line); err != nil { return err } } return writer.Flush() } // PopulateConfig populates the CurdConfig struct from a map func PopulateConfig(configMap map[string]string) CurdConfig { config := CurdConfig{} configValue := reflect.ValueOf(&config).Elem() for i := 0; i < configValue.NumField(); i++ { field := configValue.Type().Field(i) tag := field.Tag.Get("config") if value, exists := configMap[tag]; exists { fieldValue := configValue.FieldByName(field.Name) if fieldValue.CanSet() { switch fieldValue.Kind() { case reflect.String: fieldValue.SetString(value) case reflect.Int: intVal, _ := strconv.Atoi(value) fieldValue.SetInt(int64(intVal)) case reflect.Bool: boolVal, _ := strconv.ParseBool(value) fieldValue.SetBool(boolVal) } } } } // Handle MpvArgs specially if mpvArgs, exists := configMap["MpvArgs"]; exists { config.MpvArgs = parseStringArray(mpvArgs) } // Validate PercentageToMarkComplete range (0-100) if config.PercentageToMarkComplete < 0 { config.PercentageToMarkComplete = 0 } else if config.PercentageToMarkComplete > 100 { config.PercentageToMarkComplete = 100 } return config } func getOrderedCategories(userCurdConfig *CurdConfig) []SelectionOption { // Define the default categories and all available labels defaultOrder := []string{"CURRENT", "ALL", "UNTRACKED", "UPDATE", "CONTINUE_LAST", "PROVIDER"} availableLabels := map[string]string{ "CURRENT": "Currently Watching", "ALL": "Show All", "UNTRACKED": "Untracked Watching", "UPDATE": "Update (Episode, Status, Score)", "CONTINUE_LAST": "Continue Last Session", "PLANNING": "Plan to Watch", "COMPLETED": "Completed", "PAUSED": "Paused", "DROPPED": "Dropped", "REWATCHING": "Rewatching", "PROVIDER": "Change Provider", } // Create ordered list to store final result finalOrder := make([]string, 0) seen := make(map[string]bool) // If no menu order specified, use default order if userCurdConfig.MenuOrder == "" { finalOrder = defaultOrder } else { // Only show items explicitly specified by user menuItems := strings.Split(userCurdConfig.MenuOrder, ",") for _, key := range menuItems { key = strings.TrimSpace(key) if _, exists := availableLabels[key]; exists && !seen[key] { finalOrder = append(finalOrder, key) seen[key] = true } } } // Create the final ordered slice of SelectionOptions orderedCategories := make([]SelectionOption, 0, len(finalOrder)) for _, key := range finalOrder { orderedCategories = append(orderedCategories, SelectionOption{ Key: key, Label: availableLabels[key], }) } return orderedCategories } ================================================ FILE: internal/curd.go ================================================ package internal import ( "bufio" "crypto/md5" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" "regexp" "runtime" "strconv" "strings" "time" "github.com/gen2brain/beeep" "github.com/pkg/browser" ) var alternateScreenActive bool func EditConfig(configFilePath string) { // Get the user's preferred editor from the EDITOR environment variable editor := os.Getenv("EDITOR") if editor == "" { // If EDITOR is not set, use system-specific defaults if runtime.GOOS == "windows" { // Try Notepad++ first if _, err := exec.LookPath("notepad++"); err == nil { editor = "notepad++" } else { editor = "notepad.exe" } } else { if _, err := exec.LookPath("vim"); err == nil { editor = "vim" } else { editor = "nano" } } } // Construct the command to open the config file cmd := exec.Command(editor, configFilePath) // Set the command to run in the current terminal cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr // Run the editor command err := cmd.Run() if err != nil { CurdOut(fmt.Sprintf("Error opening config file: %v", err)) return } CurdOut("Config file edited successfully.") } // ClearLogFile removes all contents from the specified log file func ClearLogFile(logFile string) error { // Open the file with truncate flag to clear its contents file, err := os.OpenFile(logFile, os.O_WRONLY|os.O_TRUNC, 0666) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } defer file.Close() return nil } // LogData logs the input data into a specified log file with the format [LOG] time lineNumber: logData func Log(data interface{}) error { logFile := GetGlobalLogFile() // Open or create the log file file, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0666) if err != nil { return err } defer file.Close() // Attempt to marshal the data into JSON jsonData, err := json.Marshal(data) if err != nil { return err } // Get the caller information _, filename, lineNumber, ok := runtime.Caller(1) // Caller 1 gives the caller of LogData if !ok { return fmt.Errorf("unable to get caller information") } // Log the current time and the JSON representation along with caller info currentTime := time.Now().Format("2006/01/02 15:04:05") logMessage := fmt.Sprintf("[LOG] %s %s:%d: %s\n", currentTime, filename, lineNumber, jsonData) _, err = fmt.Fprint(file, logMessage) // Write to the file if err != nil { return err } return nil } // ClearScreen clears the terminal screen and saves the state func ClearScreen() { userCurdConfig := GetGlobalConfig() if userCurdConfig == nil { return } if userCurdConfig.AlternateScreen == false { return } fmt.Print("\033[?1049h") // Switch to alternate screen buffer fmt.Print("\033[2J") // Clear the entire screen fmt.Print("\033[H") // Move cursor to the top left alternateScreenActive = true } // RestoreScreen restores the original terminal state func RestoreScreen() { if !alternateScreenActive { return } fmt.Print("\033[?1049l") // Switch back to the main screen buffer alternateScreenActive = false } func ExitCurd(err error) { RestoreScreen() anime := GetGlobalAnime() if (anime != nil) && (anime.Ep.Player.SocketPath != "") { _, err = MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"quit"}) if err != nil { Log("Error closing MPV: " + err.Error()) } } CurdOut("Have a great day!") // If the error is not about the connection refused, print the error if err != nil && !strings.Contains(err.Error(), "dial unix "+anime.Ep.Player.SocketPath+": connect: connection refused") { CurdOut(fmt.Sprintf("Error: %v", err)) if runtime.GOOS == "windows" { fmt.Println("Press Enter to exit") var wait string fmt.Scanln(&wait) os.Exit(1) } else { os.Exit(1) } } os.Exit(0) } func CurdOut(data interface{}) { userCurdConfig := GetGlobalConfig() if userCurdConfig == nil { userCurdConfig = &CurdConfig{} } if !userCurdConfig.RofiSelection { fmt.Println(fmt.Sprintf("%v", data)) } else { switch runtime.GOOS { case "windows": err := beeep.Notify( "Curd", fmt.Sprintf("%v", data), "", ) if err != nil { Log(fmt.Sprintf("Failed to send notification: %v", err)) } case "linux": // Check if the input starts with "-i" for image notification dataStr := fmt.Sprintf("%v", data) if strings.HasPrefix(dataStr, "-i") && userCurdConfig.ImagePreview && userCurdConfig.RofiSelection { // Split the string to get image path and message parts := strings.SplitN(dataStr, " ", 3) if len(parts) == 3 { // Remove quotes from the message message := strings.Trim(parts[2], "\"") cmd := exec.Command("notify-send", "-a", "Curd", "-h", "string:x-canonical-private-synchronous:curd-notification", "Curd", "-i", parts[1], message) err := cmd.Run() if err != nil { Log(fmt.Sprintf("%v", cmd)) Log(fmt.Sprintf("Failed to send notification: %v", err)) } } } else { cmd := exec.Command("notify-send", "-a", "Curd", "-h", "string:x-canonical-private-synchronous:curd-notification", "Curd", dataStr) err := cmd.Run() if err != nil { Log(fmt.Sprintf("%v", cmd)) Log(fmt.Sprintf("Failed to send notification: %v", err)) } } } } } func UpdateAnimeEntry(userCurdConfig *CurdConfig, user *User) { // Create update options updateOptions := []SelectionOption{ {Key: "CATEGORY", Label: "Change Anime Category"}, {Key: "PROGRESS", Label: "Change Progress"}, {Key: "SCORE", Label: "Add/Change Score"}, } // Navigation loop for update option selection updateOptionLoop: for { // Select update option updateSelection, err := DynamicSelect(updateOptions) if err != nil { Log(fmt.Sprintf("Failed to select update option: %v", err)) ExitCurd(fmt.Errorf("Failed to select update option")) } if updateSelection.Key == "-1" { ExitCurd(nil) } // Back from update selection returns to home menu if updateSelection.Key == "-2" { return // Return to caller (home menu) } // Get user's anime list var animeListOptions []SelectionOption var animeListMapPreview map[string]RofiSelectPreview if user.ListSync != nil { user.AnimeList = user.ListSync.Current() } if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { animeListMapPreview = buildCategoryPreviewOptions(user.AnimeList, "ALL") } else { animeListOptions = buildCategorySelectionOptions(user.AnimeList, "ALL") } // Anime selection loop animeSelectLoop: for { // Select anime to update var selectedAnime SelectionOption if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { selectedAnime, err = DynamicSelectPreviewWithRefresh(animeListMapPreview, false, &PreviewSelectionRefreshConfig{ Updates: user.ListSync.Updates(), BuildOptions: func(list AnimeList) map[string]RofiSelectPreview { return buildCategoryPreviewOptions(list, "ALL") }, }) } else { selectedAnime, err = DynamicSelectWithRefresh(animeListOptions, &SelectionRefreshConfig{ Updates: user.ListSync.Updates(), BuildOptions: func(list AnimeList) []SelectionOption { return buildCategorySelectionOptions(list, "ALL") }, }) } if err != nil { Log(fmt.Sprintf("Failed to select anime: %v", err)) ExitCurd(fmt.Errorf("Failed to select anime")) } if selectedAnime.Key == "-1" { ExitCurd(nil) } // Back from anime selection goes to update option selection if selectedAnime.Key == "-2" { ClearScreen() continue updateOptionLoop } animeID, err := strconv.Atoi(selectedAnime.Key) if err != nil { Log(fmt.Sprintf("Failed to convert anime ID: %v", err)) ExitCurd(fmt.Errorf("Failed to convert anime ID")) } if user.ListSync != nil { user.AnimeList = user.ListSync.Current() } // After getting animeID, get the current anime entry selectedAnilistAnime, err := FindAnimeByAnilistID(user.AnimeList, selectedAnime.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")) } ClearScreen() // Final selection loop (for category/progress/score) for { switch updateSelection.Key { case "CATEGORY": categories := []SelectionOption{ {Key: "CURRENT", Label: "Currently Watching"}, {Key: "COMPLETED", Label: "Completed"}, {Key: "PAUSED", Label: "On Hold"}, {Key: "DROPPED", Label: "Dropped"}, {Key: "PLANNING", Label: "Plan to Watch"}, {Key: "REPEATING", Label: "Rewatching"}, // Anilist uses REPEATING for rewatching } currentStatus := "None" if selectedAnilistAnime.Status != "" { // Find the label for the current status for _, cat := range categories { if cat.Key == selectedAnilistAnime.Status { currentStatus = cat.Label break } } } CurdOut(fmt.Sprintf("Current category: %s", currentStatus)) categorySelection, err := DynamicSelect(categories) 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) } // Back from category selection goes to anime selection if categorySelection.Key == "-2" { ClearScreen() continue animeSelectLoop } err = UpdateAnimeStatus(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")) } case "PROGRESS": currentProgress := "None" if selectedAnilistAnime.Progress > 0 { currentProgress = strconv.Itoa(selectedAnilistAnime.Progress) } var progress string if userCurdConfig.RofiSelection { progress, err = GetUserInputFromRofi(fmt.Sprintf("Current progress: %s - Enter new progress (episode number)", currentProgress)) if err != nil { Log(fmt.Sprintf("Failed to get progress input: %v", err)) ExitCurd(fmt.Errorf("Failed to get progress input")) } } else { CurdOut(fmt.Sprintf("Current progress: %s", currentProgress)) CurdOut("Enter new progress (episode number):") fmt.Scanln(&progress) } progressNum, err := strconv.Atoi(progress) if err != nil { Log(fmt.Sprintf("Failed to convert progress to number: %v", err)) ExitCurd(fmt.Errorf("Failed to convert progress to number")) } err = UpdateAnimeProgress(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")) } case "SCORE": currentScore := "None" if selectedAnilistAnime.Score > 0 { currentScore = strconv.Itoa(int(selectedAnilistAnime.Score)) } CurdOut(fmt.Sprintf("Current score: %s", currentScore)) err = RateAnime(user.Token, animeID) if err != nil { Log(fmt.Sprintf("Failed to update anime score: %v", err)) ExitCurd(fmt.Errorf("Failed to update anime score")) } } if err := RefreshUserAnimeList(userCurdConfig, user); err != nil { Log(fmt.Sprintf("Failed to refresh anime list: %v", err)) ExitCurd(fmt.Errorf("Failed to refresh anime list")) } CurdOut("Anime updated successfully!") return } } } } func UpdateCurd(repo, fileName string) error { // Get the path of the currently running executable executablePath, err := os.Executable() if err != nil { return fmt.Errorf("unable to find current executable: %v", err) } // Determine the correct binary name based on OS and architecture var binaryName string switch runtime.GOOS { case "windows": if runtime.GOARCH == "arm64" { binaryName = "curd-windows-arm64.exe" } else { binaryName = "curd-windows-x86_64.exe" } case "darwin": // macOS switch runtime.GOARCH { case "amd64": binaryName = "curd-macos-x86_64" case "arm64": binaryName = "curd-macos-arm64" default: binaryName = "curd-macos-universal" } case "linux": switch runtime.GOARCH { case "amd64": binaryName = "curd-linux-x86_64" case "arm64": binaryName = "curd-linux-arm64" default: return fmt.Errorf("unsupported Linux architecture: %s", runtime.GOARCH) } default: return fmt.Errorf("unsupported operating system: %s", runtime.GOOS) } // GitHub release URL for curd url := fmt.Sprintf("https://github.com/%s/releases/latest/download/%s", repo, binaryName) // Temporary path for the downloaded curd executable tmpPath := executablePath + ".tmp" // Download the curd executable resp, err := http.Get(url) if err != nil { return fmt.Errorf("failed to download file: %v", err) } defer resp.Body.Close() // Check if the download was successful if resp.StatusCode != http.StatusOK { return fmt.Errorf("failed to download file: received status code %d", resp.StatusCode) } // Create a new temporary file out, err := os.Create(tmpPath) if err != nil { return fmt.Errorf("failed to create temporary file: %v", err) } defer out.Close() // Set file permissions if err := out.Chmod(0755); err != nil { return fmt.Errorf("failed to set file permissions: %v", err) } // Copy the downloaded content to the temporary file if _, err := io.Copy(out, resp.Body); err != nil { return fmt.Errorf("failed to write to temporary file: %v", err) } // Close the file before renaming out.Close() // Replace the old executable with the new one if runtime.GOOS == "windows" { // On Windows, we need to rename the old file first oldPath := executablePath + ".old" err = os.Rename(executablePath, oldPath) if err != nil { return fmt.Errorf("failed to rename old executable: %v", err) } err = os.Rename(tmpPath, executablePath) if err != nil { // Try to restore the old executable if the rename fails os.Rename(oldPath, executablePath) return fmt.Errorf("failed to rename new executable: %v", err) } os.Remove(oldPath) } else { // On Unix systems, we can directly rename if err := os.Rename(tmpPath, executablePath); err != nil { return fmt.Errorf("failed to replace executable: %v", err) } } return nil } func AddNewAnime(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAnimes *[]Anime) SelectionOption { var query string // Remove the redeclared variable declaration since animeOptions is already declared above var animeMapPreview map[string]RofiSelectPreview var animeOptions []SelectionOption var err error var anilistSelectedOption SelectionOption if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Enter the anime name") if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } query = userInput } else { CurdOut("Enter the anime name:") reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') query = strings.TrimSpace(input) } if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { animeMapPreview, err = SearchAnimeAnilistPreview(query, user.Token) } else { animeOptions, err = SearchAnimeAnilist(query, user.Token) if err != nil { Log(fmt.Sprintf("Failed to search anime: %v", err)) ExitCurd(fmt.Errorf("Failed to search anime")) } } if err != nil { Log(fmt.Sprintf("Failed to search anime: %v", err)) ExitCurd(fmt.Errorf("Failed to search anime")) } if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { anilistSelectedOption, err = DynamicSelectPreview(animeMapPreview, false) } else { anilistSelectedOption, err = DynamicSelect(animeOptions) } if anilistSelectedOption.Key == "-1" { ExitCurd(nil) } // Handle back button - return to caller 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")) } animeID, err := strconv.Atoi(anilistSelectedOption.Key) if err != nil { Log(fmt.Sprintf("Failed to convert anime ID to integer: %v", err)) ExitCurd(fmt.Errorf("Failed to convert anime ID to integer")) } // Add category selection before adding to list categories := []SelectionOption{ {Key: "CURRENT", Label: "Currently Watching"}, {Key: "COMPLETED", Label: "Completed"}, {Key: "PAUSED", Label: "On Hold"}, {Key: "DROPPED", Label: "Dropped"}, {Key: "PLANNING", Label: "Plan to Watch"}, {Key: "REPEATING", Label: "Rewatching"}, // Anilist uses REPEATING for rewatching } ClearScreen() CurdOut("Select which list to add the anime to:") categorySelection, err := DynamicSelect(categories) 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) } // Handle back button - return to caller if categorySelection.Key == "-2" { return SelectionOption{Key: "-2", Label: "Back"} } err = UpdateAnimeStatus(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")) } if err := RefreshUserAnimeList(userCurdConfig, user); err != nil { Log(fmt.Sprintf("Failed to refresh anime list: %v", err)) ExitCurd(fmt.Errorf("Failed to refresh anime list")) } return anilistSelectedOption } func SetupCurd(userCurdConfig *CurdConfig, anime *Anime, user *User, databaseAnimes *[]Anime) { var err error var startingRewatch bool // Filter anime list based on selected category var animeListOptions []SelectionOption var animeListMapPreview map[string]RofiSelectPreview // Initialize anime list. On a cache hit this is instant (reads from disk) and // the user ID is seeded from the cached payload, avoiding a blocking network // round-trip to AniList. The real user ID + latest list are refreshed in the // background goroutine inside InitializeUserAnimeList. if err := InitializeUserAnimeList(userCurdConfig, user); err != nil { Log(fmt.Sprintf("Failed to initialize anime list: %v", err)) ExitCurd(fmt.Errorf("Failed to get user data\nYou can reset the token by running `curd -change-token`")) } // Variables for selection results (used in both branches and after) var anilistSelectedOption SelectionOption var selectedAllanimeAnime SelectionOption var userQuery string _ = selectedAllanimeAnime // Used later in the function // Navigation loop for the entire setup process for { startingRewatch = false // 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 idFilePath := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_id") idBytes, err := os.ReadFile(idFilePath) if err != nil { Log("Error reading curd_id file: " + err.Error()) ExitCurd(fmt.Errorf("No last watched anime found")) } anilistID, err := strconv.Atoi(string(idBytes)) if err != nil { Log("Error converting anilist ID: " + err.Error()) ExitCurd(fmt.Errorf("Invalid anime ID in curd_id file")) } // Find the anime in database animePointer := LocalFindAnime(*databaseAnimes, anilistID, "") if animePointer == nil { ExitCurd(fmt.Errorf("Last watched anime not found in database")) } // Set the anime details anime.AnilistId = animePointer.AnilistId anilistSelectedOption.Key = strconv.Itoa(animePointer.AnilistId) // anime.ProviderId = animePointer.ProviderId // anime.Title = animePointer.Title // anime.Ep.Number = animePointer.Ep.Number // anime.Ep.Player.PlaybackTime = animePointer.Ep.Player.PlaybackTime // anime.Ep.Resume = true } else { // Navigation loop for category and anime selection categorySelectionLoop: for { // 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) // 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 categorySelection.Key == "-1" { ExitCurd(nil) } if categorySelection.Key == "-2" { continue } // Handle options if categorySelection.Key == "PROVIDER" { ClearScreen() ChangeProvider(userCurdConfig) ClearScreen() continue categorySelectionLoop } else if categorySelection.Key == "UPDATE" { ClearScreen() UpdateAnimeEntry(userCurdConfig, user) // If UpdateAnimeEntry returns, user pressed back - continue to category selection ClearScreen() continue categorySelectionLoop } else if categorySelection.Key == "UNTRACKED" { ClearScreen() WatchUntracked(userCurdConfig) // If WatchUntracked returns, user pressed back OR watched is done- continue to category selection ClearScreen() continue categorySelectionLoop } else if categorySelection.Key == "CONTINUE_LAST" { anime.Ep.ContinueLast = true } ClearScreen() } if user.ListSync != nil { user.AnimeList = user.ListSync.Current() } if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { animeListMapPreview = buildCategoryPreviewOptions(user.AnimeList, categorySelection.Key) } else { animeListOptions = buildCategorySelectionOptions(user.AnimeList, categorySelection.Key) } // Anime selection loop (for back navigation) animeSelectionLoop: for { 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")) } 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")) } anime.AnilistId = lastWatchedID anilistSelectedOption.Key = strconv.Itoa(lastWatchedID) break categorySelectionLoop } // Select anime to watch (Anilist) var err error if userCurdConfig.RofiSelection && userCurdConfig.ImagePreview { anilistSelectedOption, err = DynamicSelectPreviewWithRefresh(animeListMapPreview, true, &PreviewSelectionRefreshConfig{ Updates: user.ListSync.Updates(), BuildOptions: func(list AnimeList) map[string]RofiSelectPreview { return buildCategoryPreviewOptions(list, categorySelection.Key) }, }) } else { // Add "Add new anime" option to the slice tempOptions := make([]SelectionOption, len(animeListOptions)) copy(tempOptions, animeListOptions) tempOptions = append(tempOptions, SelectionOption{ Key: "add_new", Label: "Add new anime", }) anilistSelectedOption, err = DynamicSelectWithRefresh(tempOptions, &SelectionRefreshConfig{ Updates: user.ListSync.Updates(), BuildOptions: func(list AnimeList) []SelectionOption { updatedOptions := buildCategorySelectionOptions(list, categorySelection.Key) updatedOptions = append(updatedOptions, SelectionOption{ Key: "add_new", Label: "Add new anime", }) return updatedOptions }, }) } if err != nil { Log(fmt.Sprintf("Error selecting anime: %v", err)) ExitCurd(fmt.Errorf("Error selecting anime")) } Log(anilistSelectedOption) if anilistSelectedOption.Key == "-1" { ExitCurd(nil) } // Handle back navigation - go back to category selection if anilistSelectedOption.Key == "-2" { if userCurdConfig.CurrentCategory { // If CurrentCategory is forced, back means quit ExitCurd(nil) } ClearScreen() continue categorySelectionLoop } if anilistSelectedOption.Label == "add_new" || anilistSelectedOption.Key == "add_new" { addResult := AddNewAnime(userCurdConfig, anime, user, databaseAnimes) if addResult.Key == "-2" { // Back from add new anime goes to anime selection ClearScreen() continue animeSelectionLoop } anilistSelectedOption = addResult } 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")) } // Successfully selected anime, break out of both loops break categorySelectionLoop } } } // Wait for the background refresh goroutine to finish before reading // progress+1. RefreshDone() is a channel that is closed (broadcast) the // instant the goroutine completes — no polling, no race with the UI // Updates consumer that already drained the updates channel. if user.ListSync != nil { select { case <-user.ListSync.RefreshDone(): Log("Background refresh done, using latest anime list for playback") case <-time.After(10 * time.Second): Log("Timed out waiting for background anime list refresh; using cached list") } user.AnimeList = user.ListSync.Current() } 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")) } if selectedAnilistAnime.Media.Status == "NOT_YET_RELEASED" { CurdOut("This anime is not yet released. Cannot play.") time.Sleep(2 * time.Second) anime.Ep.ContinueLast = false ClearScreen() continue } // Set anime entry anime.Title = selectedAnilistAnime.Media.Title anime.TotalEpisodes = selectedAnilistAnime.Media.Episodes anime.CoverImage = selectedAnilistAnime.CoverImage if anime.MalId == 0 { anime.MalId, _ = GetAnimeMalID(anime.AnilistId) } anime.IsAiring = selectedAnilistAnime.Media.Status == "RELEASING" || selectedAnilistAnime.Media.Status == "NOT_YET_RELEASED" anime.Rewatching = selectedAnilistAnime.Status == "REPEATING" anime.Repeat = selectedAnilistAnime.Repeat anime.StartedAt = selectedAnilistAnime.StartedAt anime.CompletedAt = selectedAnilistAnime.CompletedAt anime.Ep.Number = selectedAnilistAnime.Progress + 1 var animeList []SelectionOption userQuery = anime.Title.Romaji if selectedAnilistAnime.Status == "COMPLETED" { status := "REPEATING" progress := 0 err = SaveAnimeListEntry(user.Token, anime.AnilistId, &status, &progress, nil, &anime.StartedAt, &anime.CompletedAt) if err != nil { Log(fmt.Sprintf("Error starting anime rewatch: %v", err)) ExitCurd(fmt.Errorf("Failed to move anime to rewatching")) } anime.Rewatching = true anime.Ep.Number = 1 anime.Ep.Player.PlaybackTime = 0 anime.Ep.Resume = false startingRewatch = true CurdOut("Moved anime to Rewatching and restarting from episode 1.") if err := RefreshUserAnimeList(userCurdConfig, user); err != nil { Log("Error refreshing anime list: " + err.Error()) ExitCurd(err) } } // Check if we need to research provider ID needsProviderSearch := false if animePointer == nil { needsProviderSearch = true } else if animePointer.ProviderName != "" && animePointer.ProviderName != GetProvider().Name() { needsProviderSearch = true } else if animePointer.ProviderName == "" && GetProvider().Name() != "allanime" { needsProviderSearch = true } // if anime not found in database or provider changed, find it in animeList if needsProviderSearch { Log("Anime not found in database for current provider, 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")) } // Prompt user for manual query only when no results were found if len(animeList) == 0 { for { var manualQuery string if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi(fmt.Sprintf("No results found for '%s'. Press Enter to search with AniList name, or enter a custom name to search on AllAnime.", userQuery)) if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } manualQuery = userInput } else { CurdOut(fmt.Sprintf("No results found for '%s'.", userQuery)) CurdOut("Press Enter to search with AniList name, or enter a custom name to search on AllAnime:") reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') manualQuery = strings.TrimSpace(input) } // If empty, use original AniList name if manualQuery == "" { manualQuery = string(userQuery) } animeList, err = SearchAnime(manualQuery, userCurdConfig.SubOrDub) if err != nil { Log(fmt.Sprintf("Failed to search anime with query '%s': %v", manualQuery, err)) ExitCurd(fmt.Errorf("Failed to search anime")) } if len(animeList) > 0 { break } } } // Automatic mapping using Thumbnail clues // 1. Try AniList and MAL thumbnail matching found := false anilistIDStr := strconv.Itoa(anime.AnilistId) var jikanUrls []string fetchedJikan := false // Helper regex // AniList regex extracts ID from strings like "bx155348-" or "/155348.jpg" anilistRegex := regexp.MustCompile(`anilistcdn/media/anime/cover/(?:large|medium)/(?:bx)?(\d+)`) // MyAnimeList regex extracts the filename like "120128.jpg" malRegex := regexp.MustCompile(`myanimelist\.net/images/anime/[^/]+/([^/]+\.jpg)`) for i, option := range animeList { Log(fmt.Sprintf("Checking option %d: Key='%s', Label='%s', Thumbnail='%s'", i, option.Key, option.Label, option.Thumbnail)) if strings.Contains(option.Thumbnail, "anilist.co") { matches := anilistRegex.FindStringSubmatch(option.Thumbnail) if len(matches) > 1 && matches[1] == anilistIDStr { anime.ProviderId = option.Key Log(fmt.Sprintf("Found Anilist Thumbnail match! Setting ProviderId to: %s", anime.ProviderId)) found = true break } } else if strings.Contains(option.Thumbnail, "myanimelist.net") { matches := malRegex.FindStringSubmatch(option.Thumbnail) if len(matches) > 1 { fileName := matches[1] // Fetch Jikan pictures lazily (only once) if !fetchedJikan { if anime.MalId == 0 { anime.MalId, _ = GetAnimeMalID(anime.AnilistId) } if anime.MalId != 0 { urls, err := FetchJikanPictures(anime.MalId) if err != nil { Log(fmt.Sprintf("Failed to fetch Jikan pictures: %v", err)) } else { jikanUrls = urls } } fetchedJikan = true } for _, url := range jikanUrls { if strings.HasSuffix(url, "/"+fileName) || strings.Contains(url, fileName) { anime.ProviderId = option.Key Log(fmt.Sprintf("Found MyAnimeList Thumbnail match (%s)! Setting ProviderId to: %s", fileName, anime.ProviderId)) found = true break } } } if found { break } } } // 2. Jikan Metadata & Exact Anilist Meta Tag Matching (for animepahe) if !found && GetProvider().Name() == "animepahe" { Log("Attempting deep metadata matching and exact AniList meta tag check for Animepahe...") targetAnilistID := strconv.Itoa(anime.AnilistId) var bestMatch *SelectionOption var highestScore int if anime.MalId == 0 { anime.MalId, _ = GetAnimeMalID(anime.AnilistId) } var malData *JikanAnimeData if anime.MalId != 0 { malData, _ = FetchJikanAnimeData(anime.MalId) } for i := range animeList { opt := &animeList[i] score := 0 if malData != nil && opt.ExtraData != nil { if paheData, ok := opt.ExtraData.(AnimepaheSearchItem); ok { paheTitleLower := strings.ToLower(paheData.Title) malTitleLower := strings.ToLower(malData.Title) malTitleEngLower := strings.ToLower(malData.TitleEnglish) malTitleJapLower := strings.ToLower(malData.TitleJapanese) if paheTitleLower == malTitleLower || (malTitleEngLower != "" && paheTitleLower == malTitleEngLower) || (malTitleJapLower != "" && paheTitleLower == malTitleJapLower) { score += 5 } else if strings.Contains(paheTitleLower, malTitleLower) || strings.Contains(malTitleLower, paheTitleLower) || (malTitleEngLower != "" && (strings.Contains(paheTitleLower, malTitleEngLower) || strings.Contains(malTitleEngLower, paheTitleLower))) { score += 2 } if paheData.Year > 0 && malData.Year > 0 && paheData.Year == malData.Year { score += 3 } if paheData.Season != "" && malData.Season != "" && strings.EqualFold(paheData.Season, malData.Season) { score += 2 } if paheData.Type != "" && malData.Type != "" && strings.EqualFold(paheData.Type, malData.Type) { score += 2 } if paheData.Episodes > 0 && malData.Episodes > 0 && paheData.Episodes == malData.Episodes { score += 2 } else if (paheData.Episodes == 0 && malData.Status == "Currently Airing") || (malData.Episodes == 0 && paheData.Status == "Currently Airing") { score += 2 } if strings.EqualFold(paheData.Status, malData.Status) { score += 1 } } } else { if strings.Contains(strings.ToLower(opt.Title), strings.ToLower(string(userQuery))) { score += 2 } } // If it has a decent score (or there are very few options), let's verify with the exact AniList meta tag if score >= 2 || len(animeList) <= 3 { animeUrl := fmt.Sprintf("https://animepahe.pw/anime/%s", opt.Key) Log(fmt.Sprintf("Fetching %s to check exact AniList meta tag...", animeUrl)) req, _ := http.NewRequest("GET", animeUrl, nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") resp, err := sharedHTTPClient.Do(req) if err == nil && resp.StatusCode == 200 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := string(body) metaTag := fmt.Sprintf(``, targetAnilistID) if strings.Contains(bodyStr, metaTag) { Log(fmt.Sprintf("FOUND EXACT ANILIST META TAG MATCH in %s!", opt.Title)) bestMatch = opt highestScore = 100 // Guarantee selection break } else { malMetaTag := fmt.Sprintf(``, anime.MalId) if anime.MalId != 0 && strings.Contains(bodyStr, malMetaTag) { Log(fmt.Sprintf("FOUND EXACT MAL META TAG MATCH in %s!", opt.Title)) bestMatch = opt highestScore = 100 break } } } } if score > highestScore { highestScore = score bestMatch = opt } } if bestMatch != nil && highestScore == 100 { anime.ProviderId = bestMatch.Key Log(fmt.Sprintf("Found EXACT meta tag match! Score: %d. Setting ProviderId to: %s", highestScore, anime.ProviderId)) found = true } else if bestMatch != nil { Log(fmt.Sprintf("Highest match score was %d (needed 100 for Animepahe exact meta tag match). Not selecting automatically to prevent false positives.", highestScore)) } } // 3. Fallback to naive title/episode match if !found { targetLabel := fmt.Sprintf("%v (%d episodes)", userQuery, selectedAnilistAnime.Media.Episodes) for _, option := range animeList { if fmt.Sprintf("%s (%d episodes)", option.Title, selectedAnilistAnime.Media.Episodes) == targetLabel { anime.ProviderId = option.Key Log(fmt.Sprintf("Found exact text match! Setting ProviderId to: %s", anime.ProviderId)) found = true break } } if !found { Log(fmt.Sprintf("No exact match found for label '%s'. Will require manual selection.", targetLabel)) } } // If unable to get Allanime id automatically get manually if anime.ProviderId == "" { CurdOut("We didn't find any matches. Please select manually.") selectedAllanimeAnime, err := DynamicSelect(animeList) if selectedAllanimeAnime.Key == "-1" { ExitCurd(nil) } // Handle back button - go back to main menu if selectedAllanimeAnime.Key == "-2" { CurdOut("Going back to main menu...") RestoreScreen() if anime.Ep.ContinueLast { anime.Ep.ContinueLast = false } continue } if err != nil { ExitCurd(fmt.Errorf("No anime available")) } anime.ProviderId = selectedAllanimeAnime.Key } } // if anime found in database, use its playback state if animePointer != nil { if !needsProviderSearch { anime.ProviderId = animePointer.ProviderId } anime.Ep.Player.PlaybackTime = animePointer.Ep.Player.PlaybackTime if anime.Ep.Number == animePointer.Ep.Number { anime.Ep.Resume = true } // If local history episode is ahead of AniList upstream, prompt user anilistEpisode := selectedAnilistAnime.Progress + 1 if animePointer.Ep.Number > anilistEpisode { Log(fmt.Sprintf("Local history episode (%d) is ahead of AniList episode (%d), prompting user", animePointer.Ep.Number, anilistEpisode)) options := []SelectionOption{ {Key: "update_upstream", Label: fmt.Sprintf("Update AniList to episode %d (local history)", animePointer.Ep.Number-1)}, {Key: "use_anilist", Label: fmt.Sprintf("Use AniList episode %d", anilistEpisode)}, } CurdOut(fmt.Sprintf("Local history (ep %d) is ahead of AniList (ep %d).", animePointer.Ep.Number, anilistEpisode)) selectedOption, err := DynamicSelect(options) if err != nil { Log("Error in episode conflict selection: " + err.Error()) } else if selectedOption.Key == "-1" { ExitCurd(nil) } else if selectedOption.Key == "update_upstream" { // Update AniList progress to match local history (progress = local ep - 1, since local ep is "next to watch") progressToUpdate := animePointer.Ep.Number - 1 if err := UpdateAnimeProgress(user.Token, anime.AnilistId, progressToUpdate); err != nil { Log(fmt.Sprintf("Error updating AniList progress to %d: %v", progressToUpdate, err)) } else { Log(fmt.Sprintf("Updated AniList progress to %d", progressToUpdate)) } anime.Ep.Number = animePointer.Ep.Number anime.Ep.Resume = true } else if selectedOption.Key == "use_anilist" { // Use AniList episode number (already set from selectedAnilistAnime.Progress + 1) anime.Ep.Number = anilistEpisode anime.Ep.Player.PlaybackTime = 0 anime.Ep.Resume = false } } else { anime.Ep.Number = animePointer.Ep.Number } } if startingRewatch { anime.Ep.Player.PlaybackTime = 0 anime.Ep.Resume = false } if selectedAllanimeAnime.Key == "-1" { ExitCurd(nil) } // If anime is not in watching list, prompt user to add it into watching list isInWatchingList := false for _, entry := range user.AnimeList.Watching { if entry.Media.ID == anime.AnilistId { isInWatchingList = true break } } if !isInWatchingList { for _, entry := range user.AnimeList.Rewatching { if entry.Media.ID == anime.AnilistId { isInWatchingList = true break } } } if anime.Rewatching { isInWatchingList = true } if !isInWatchingList { // Create options for the prompt options := []SelectionOption{ {Key: "yes", Label: "Add to watching list"}, {Key: "no", Label: "Continue without adding"}, } var selectedOption SelectionOption var err error // Use rofi for selection selectedOption, err = DynamicSelect(options) if err != nil { Log("Error in selection: " + err.Error()) ExitCurd(err) } if selectedOption.Key == "yes" { err = AddAnimeToWatchingList(anime.AnilistId, user.Token) if err != nil { Log("Error adding anime to watching list: " + err.Error()) ExitCurd(err) } if err := RefreshUserAnimeList(userCurdConfig, user); err != nil { Log("Error refreshing anime list: " + err.Error()) ExitCurd(err) } } else if selectedOption.Key == "-1" { ExitCurd(nil) } else if selectedOption.Key == "-2" { // Handle back button - go back to main menu CurdOut("Going back to main menu...") RestoreScreen() // If we were continuing last, disable it so we go to menu next loop if anime.Ep.ContinueLast { anime.Ep.ContinueLast = false } continue } } // If upstream is ahead, update the episode number if temp_anime, err := FindAnimeByAnilistID(user.AnimeList, strconv.Itoa(anime.AnilistId)); err == nil { if temp_anime.Progress > anime.Ep.Number { anime.Ep.Number = temp_anime.Progress anime.Ep.Player.PlaybackTime = 0 anime.Ep.Resume = false } } if anime.TotalEpisodes == 0 { // Get updated anime data Log(selectedAllanimeAnime) updatedAnime, err := GetAnimeDataByID(anime.AnilistId, user.Token) Log(updatedAnime) if err != nil { Log(fmt.Sprintf("Error getting updated anime data: %v", err)) } else { anime.TotalEpisodes = updatedAnime.TotalEpisodes Log(fmt.Sprintf("Updated total episodes: %d", anime.TotalEpisodes)) } } 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) if err != nil { CurdOut(fmt.Sprintf("Failed to retrieve anime list: %v", err)) } else { for _, option := range animeList { if option.Key == anime.ProviderId { // Extract total episodes from the label if matches := regexp.MustCompile(`\((\d+) episodes\)`).FindStringSubmatch(option.Label); len(matches) > 1 { anime.TotalEpisodes, _ = strconv.Atoi(matches[1]) CurdOut(fmt.Sprintf("Retrieved total episodes: %d", anime.TotalEpisodes)) break } } } } if anime.TotalEpisodes == 0 { CurdOut("Still unable to determine total episodes.") CurdOut(fmt.Sprintf("Your AniList progress: %d", selectedAnilistAnime.Progress)) var episodeNumber int if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Enter the episode you want to start from") if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } episodeNumber, err = strconv.Atoi(userInput) } else { fmt.Print("Enter the episode you want to start from: ") fmt.Scanln(&episodeNumber) } anime.Ep.Number = episodeNumber } else { anime.Ep.Number = selectedAnilistAnime.Progress + 1 } } else if anime.TotalEpisodes < anime.Ep.Number { // Handle weird cases Log(fmt.Sprintf("Weird case: anime.TotalEpisodes < anime.Ep.Number: %v < %v", anime.TotalEpisodes, anime.Ep.Number)) var answer string if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Would like to start the anime from beginning? (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 like to start the anime from beginning? (y/n)\n") fmt.Scanln(&answer) } if answer == "y" { anime.Ep.Number = 1 } else { anime.Ep.Number = anime.TotalEpisodes } } // If we reached here successfully, break the loop break } } // CreateOrWriteTokenFile creates the token file if it doesn't exist and writes the token to it func WriteTokenToFile(token string, filePath string) error { // Extract the directory path dir := filepath.Dir(filePath) // Create all necessary parent directories if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("failed to create directories: %v", err) } // Write the token to the file, creating it if it doesn't exist err := os.WriteFile(filePath, []byte(token), 0644) if err != nil { return fmt.Errorf("failed to write token to file: %v", err) } return nil } func StartCurd(userCurdConfig *CurdConfig, anime *Anime) string { // Validate inputs if anime.ProviderId == "" { CurdOut("Error: No anime ID found") os.Exit(1) } if anime.Ep.Number <= 0 { CurdOut("Error: Invalid episode number") os.Exit(1) } if (anime.Ep.NextEpisode.Number == anime.Ep.Number) && (len(anime.Ep.NextEpisode.Links) > 0) { anime.Ep.Links = anime.Ep.NextEpisode.Links } else { // Get episode link link, err := GetEpisodeURL(*userCurdConfig, anime.ProviderId, anime.Ep.Number) if len(link) > 0 { Log(fmt.Sprintf("Links details: %+v", link)) } if err != nil { Log(fmt.Sprintf("GetEpisodeURL failed: %v", err)) // If unable to get episode link automatically get manually episodeList, err := EpisodesList(anime.ProviderId, userCurdConfig.SubOrDub) if err != nil { CurdOut("No episode list found: " + err.Error()) Log(fmt.Sprintf("EpisodesList failed: %v", err)) RestoreScreen() os.Exit(1) } if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi(fmt.Sprintf("Enter the episode (%v episodes)", episodeList[len(episodeList)-1])) if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } anime.Ep.Number, err = strconv.Atoi(userInput) } else { CurdOut(fmt.Sprintf("Enter the episode (%v episodes)", episodeList[len(episodeList)-1])) fmt.Scanln(&anime.Ep.Number) } link, err = GetEpisodeURL(*userCurdConfig, anime.ProviderId, anime.Ep.Number) if err != nil { CurdOut("Failed to get episode link") os.Exit(1) } } else { Log(fmt.Sprintf("Successfully retrieved episode link on first try. Links count: %d", len(link))) } anime.Ep.Links = link } if len(anime.Ep.Links) == 0 { CurdOut("No episode links found") os.Exit(1) } else { Log(fmt.Sprintf("Episode links validation passed. Found %d links", len(anime.Ep.Links))) } // Modify the goroutine in main.go where next episode links are fetched // Get next episode link in parallel go func() { nextEpNum := anime.Ep.Number + 1 if nextEpNum <= anime.TotalEpisodes { // Get next canon episode number if filler skip is enabled if userCurdConfig.SkipFiller && IsEpisodeFiller(anime.FillerEpisodes, anime.Ep.Number) { nextEpNum = GetNextCanonEpisode(anime.FillerEpisodes, nextEpNum) } nextLinks, err := GetEpisodeURL(*userCurdConfig, anime.ProviderId, nextEpNum) if err != nil { Log(fmt.Sprintf("Error getting next episode link for ep %d: %v", nextEpNum, err)) } else { anime.Ep.NextEpisode = NextEpisode{ Number: nextEpNum, Links: nextLinks, } } } else { Log(fmt.Sprintf("Next episode %d exceeds total episodes %d, skipping prefetch", nextEpNum, anime.TotalEpisodes)) } }() // Write anime.AnilistId to curd_id in the storage path idFilePath := filepath.Join(os.ExpandEnv(userCurdConfig.StoragePath), "curd_id") Log(fmt.Sprintf("idFilePath: %v", idFilePath)) if err := os.MkdirAll(filepath.Dir(idFilePath), 0755); err != nil { Log(fmt.Sprintf("Failed to create directory for curd_id: %v", err)) } else { if err := os.WriteFile(idFilePath, []byte(fmt.Sprintf("%d", anime.AnilistId)), 0644); err != nil { Log(fmt.Sprintf("Failed to write AnilistId to file: %v", err)) } } // Display starting message with cover image and episode info if anime.CoverImage != "" && userCurdConfig.ImagePreview && userCurdConfig.RofiSelection { // Get the cached image path cacheDir := os.ExpandEnv("${HOME}/.cache/curd/images") filename := fmt.Sprintf("%x.jpg", md5.Sum([]byte(anime.CoverImage))) cachePath := filepath.Join(cacheDir, filename) // Display the image if it exists in cache _, err := os.Stat(cachePath) if err == nil { // File exists Log(fmt.Sprintf("Image found at %s", cachePath)) CurdOut(fmt.Sprintf("-i %s \"%s - Episode %d\"", cachePath, GetAnimeName(*anime), anime.Ep.Number)) } else { // File does not exist Log(fmt.Sprintf("Image does not exist at %s", cachePath)) CurdOut(fmt.Sprintf("%s - Episode %d", GetAnimeName(*anime), anime.Ep.Number)) } } else { CurdOut(fmt.Sprintf("%s - Episode %d", GetAnimeName(*anime), anime.Ep.Number)) } mpvSocketPath, err := StartVideo(PrioritizeLink(anime.Ep.Links), []string{}, fmt.Sprintf("%s - Episode %d", GetAnimeName(*anime), anime.Ep.Number), anime) if err != nil { Log("Failed to start mpv") os.Exit(1) } return mpvSocketPath } func CheckAndDownloadFiles(storagePath string, filesToCheck []string) error { // Create storage directory if it doesn't exist storagePath = os.ExpandEnv(storagePath) if err := os.MkdirAll(storagePath, 0755); err != nil { return fmt.Errorf("failed to create storage directory: %v", err) } // Base URL for downloading config files baseURL := "https://raw.githubusercontent.com/Wraient/curd/refs/heads/main/rofi/" // Check each file for _, fileName := range filesToCheck { filePath := filepath.Join(os.ExpandEnv(storagePath), fileName) // Skip if file already exists if _, err := os.Stat(filePath); err == nil { continue } // Download file if it doesn't exist resp, err := http.Get(baseURL + fileName) if err != nil { return fmt.Errorf("failed to download %s: %v", fileName, err) } defer resp.Body.Close() // Create the file out, err := os.Create(filePath) if err != nil { return fmt.Errorf("failed to create file %s: %v", fileName, err) } defer out.Close() // Write the content if _, err := io.Copy(out, resp.Body); err != nil { return fmt.Errorf("failed to write file %s: %v", fileName, err) } } return nil } func getEntriesByCategory(list AnimeList, category string) []Entry { switch category { case "ALL": // Combine all categories into one slice allEntries := make([]Entry, 0) allEntries = append(allEntries, list.Watching...) allEntries = append(allEntries, list.Completed...) allEntries = append(allEntries, list.Paused...) allEntries = append(allEntries, list.Dropped...) allEntries = append(allEntries, list.Planning...) allEntries = append(allEntries, list.Rewatching...) return allEntries case "CURRENT": currentEntries := make([]Entry, 0, len(list.Watching)+len(list.Rewatching)) currentEntries = append(currentEntries, list.Watching...) currentEntries = append(currentEntries, list.Rewatching...) return currentEntries case "COMPLETED": return list.Completed case "PAUSED": return list.Paused case "DROPPED": return list.Dropped case "PLANNING": return list.Planning case "REWATCHING": // Added for completeness, though "ALL" covers it. return list.Rewatching default: return []Entry{} } } func NextEpisodePromptCLI(userCurdConfig *CurdConfig) bool { anime := GetGlobalAnime() // Show the next episode number that will be started nextEpisodeNum := anime.Ep.Number + 1 CurdOut(fmt.Sprintf("Start next episode (%d)?", nextEpisodeNum)) // 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)}, } // Use DynamicSelect for CLI mode selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in CLI next episode prompt selection: %v", err)) return false } Log(fmt.Sprintf("CLI User Selected Key: '%s', Label: '%s'", selectedOption.Key, selectedOption.Label)) if selectedOption.Key == "-1" || selectedOption.Key == "-2" { // User selected to quit/back via the built-in option CurdOut("Exiting") return false } return selectedOption.Key == "yes" } // NextEpisodePromptContinuous provides a continuous next episode prompt for CLI mode // This runs throughout the episode duration and handles completion logic func NextEpisodePromptContinuous(userCurdConfig *CurdConfig, databaseFile string, userToken string) { anime := GetGlobalAnime() for { // Check if episode has started if !anime.Ep.Started { time.Sleep(1 * time.Second) continue } // Show the next episode number that will be started nextEpisodeNum := anime.Ep.Number + 1 CurdOut(fmt.Sprintf("Continue to next episode (%d) or quit?", nextEpisodeNum)) // 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"}, } // Use DynamicSelect for CLI mode selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in CLI continuous next episode prompt: %v", err)) break } Log(fmt.Sprintf("CLI Continuous User Selected Key: '%s', Label: '%s'", selectedOption.Key, selectedOption.Label)) if selectedOption.Key == "-1" || selectedOption.Key == "-2" { // User selected to quit/back via the built-in 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 // Handle completion if this was the last episode if anime.Ep.Number == anime.TotalEpisodes { HandleLastEpisodeCompletion(userCurdConfig, anime, userToken) } // Update local database err = LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, ConvertSecondsToMinutes(anime.Ep.Duration), GetAnimeName(*anime), GetProvider().Name()) if err != nil { Log("Error updating local database on quit: " + err.Error()) } go func() { err = UpdateAnimeProgress(userToken, anime.AnilistId, anime.Ep.Number) if err != nil { Log("Error updating Anilist progress on quit: " + err.Error()) } }() 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 == "yes" { // User wants to start next episode immediately anime.Ep.IsCompleted = true // Update database with completed episode first err = LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, anime.Ep.Player.PlaybackTime, ConvertSecondsToMinutes(anime.Ep.Duration), GetAnimeName(*anime), GetProvider().Name()) if err != nil { Log("Error updating local database with completed episode: " + err.Error()) } go func() { err = UpdateAnimeProgress(userToken, anime.AnilistId, anime.Ep.Number) if err != nil { Log("Error updating Anilist progress: " + err.Error()) } }() // Increment to next episode and update database with next episode number and 0 playback time anime.Ep.Number++ // Use prefetched links if available for the next episode if (anime.Ep.NextEpisode.Number == anime.Ep.Number) && (len(anime.Ep.NextEpisode.Links) > 0) { anime.Ep.Links = anime.Ep.NextEpisode.Links Log(fmt.Sprintf("Using prefetched links for episode %d", anime.Ep.Number)) } else { // Clear links to force fetching new ones anime.Ep.Links = []string{} Log(fmt.Sprintf("No prefetched links available for episode %d, will fetch new ones", anime.Ep.Number)) } err = LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, 0, 0, GetAnimeName(*anime), GetProvider().Name()) if err != nil { Log("Error updating local database with next episode: " + err.Error()) } CurdOut("Starting next episode now...") ExitMPV(anime.Ep.Player.SocketPath) return // Exit this function, let the main loop handle next episode } } } // Simple next episode prompt for Rofi mode - just asks if user wants to continue func NextEpisodePromptRofi(userCurdConfig *CurdConfig) bool { anime := GetGlobalAnime() // Show the next episode number that will be started nextEpisodeNum := anime.Ep.Number + 1 // Create options for the selection options := []SelectionOption{ {Key: "yes", Label: fmt.Sprintf("Yes, start episode %d", nextEpisodeNum)}, } // Use DynamicSelect for Rofi mode selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in next episode prompt selection: %v", err)) return false } Log(fmt.Sprintf("Rofi User Selected Key: '%s', Label: '%s'", selectedOption.Key, selectedOption.Label)) return selectedOption.Key == "yes" } // StartNextEpisode handles the logic for starting the next episode // It updates the episode number, resets necessary flags, and handles database updates func StartNextEpisode(anime *Anime, userCurdConfig *CurdConfig, databaseFile string, userToken string) { // Save previous episode number for progress update prevEpisode := anime.Ep.Number // Check if we just completed the last episode if anime.TotalEpisodes > 0 && anime.Ep.Number == anime.TotalEpisodes { // Handle scoring and completion for the last episode HandleLastEpisodeCompletion(userCurdConfig, anime, userToken) err := UpdateAnimeProgress(userToken, anime.AnilistId, prevEpisode) if err != nil { Log("Error updating Anilist progress: " + err.Error()) } // Note: UpdateAnimeProgress already outputs a message on success CurdOut("Series completed!") ExitCurd(nil) return } // Increment episode number anime.Ep.Number++ // Check if we've reached the end of the series if anime.TotalEpisodes > 0 && anime.Ep.Number > anime.TotalEpisodes { CurdOut("Reached end of series") ExitCurd(nil) return } // Use prefetched links if available for the next episode if (anime.Ep.NextEpisode.Number == anime.Ep.Number) && (len(anime.Ep.NextEpisode.Links) > 0) { anime.Ep.Links = anime.Ep.NextEpisode.Links Log(fmt.Sprintf("Using prefetched links for episode %d", anime.Ep.Number)) } else { // Clear links to force fetching new ones anime.Ep.Links = []string{} Log(fmt.Sprintf("No prefetched links available for episode %d, will fetch new ones", anime.Ep.Number)) } // Reset episode flags anime.Ep.Started = false anime.Ep.IsCompleted = false // Log the transition Log("Completed episode, starting next.") // Update local database err := LocalUpdateAnime(databaseFile, anime.AnilistId, anime.ProviderId, anime.Ep.Number, 0, 0, GetAnimeName(*anime), GetProvider().Name()) if err != nil { Log("Error updating local database: " + err.Error()) } go func() { err = UpdateAnimeProgress(userToken, anime.AnilistId, prevEpisode) if err != nil { Log("Error updating Anilist progress: " + err.Error()) } }() // Output message to user CurdOut(fmt.Sprint("Starting next episode: ", anime.Ep.Number)) } // HandleLastEpisodeCompletion handles scoring and completion for the last episode func HandleLastEpisodeCompletion(userCurdConfig *CurdConfig, anime *Anime, userToken string) { // Check if this is the last episode and scoring is enabled if userCurdConfig.ScoreOnCompletion && anime.TotalEpisodes > 0 && anime.Ep.Number == anime.TotalEpisodes && !anime.IsAiring { // Prompt user to score the anime CurdOut("You've completed this anime! Would you like to rate it?") scoreOptions := []SelectionOption{ {Key: "yes", Label: "Yes, rate this anime"}, {Key: "no", Label: "No, skip rating"}, } selectedOption, err := DynamicSelect(scoreOptions) if err != nil { Log(fmt.Sprintf("Error in score prompt selection: %v", err)) } else if selectedOption.Key == "yes" { err = RateAnime(userToken, anime.AnilistId) if err != nil { Log(fmt.Sprintf("Error rating anime: %v", err)) CurdOut("Failed to rate anime") } else { CurdOut("Anime rated successfully!") } } // Back (-2) and no are treated as skip } // Update anime status to completed on AniList. // For rewatches, preserve the original completion date and only increment repeat count. if anime.TotalEpisodes > 0 && anime.Ep.Number == anime.TotalEpisodes && !anime.IsAiring { if anime.Rewatching { err := CompleteAnimeRewatch(userToken, *anime) if err != nil { Log("Error completing anime rewatch: " + err.Error()) } } else { err := UpdateAnimeStatus(userToken, anime.AnilistId, "COMPLETED") if err != nil { Log("Error updating anime status to completed: " + err.Error()) } // Note: UpdateAnimeStatus already outputs a message on success } } // Check for sequel after completion (only if this is the last episode) if anime.TotalEpisodes > 0 && anime.Ep.Number == anime.TotalEpisodes { handleSequelCheck(userCurdConfig, anime, userToken) } } // handleSequelCheck checks for sequels and prompts the user accordingly func handleSequelCheck(userCurdConfig *CurdConfig, anime *Anime, userToken string) { // Recover from any panics in this function to prevent crashes defer func() { if r := recover(); r != nil { Log(fmt.Sprintf("Recovered from panic in handleSequelCheck: %v", r)) } }() // Fetch sequel information sequels, err := GetAnimeSequel(anime.AnilistId, userToken) if err != nil { Log(fmt.Sprintf("Error fetching sequel information: %v", err)) return } if len(sequels) == 0 { Log("No sequel found for this anime") return } if len(sequels) > 1 { CurdOut("Multiple sequels found for this anime.") options := []SelectionOption{ {Key: "yes", Label: "Open AniList page to view sequels"}, {Key: "no", Label: "Ignore"}, } selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in multiple sequel prompt: %v", err)) return } if selectedOption.Key == "yes" { url := fmt.Sprintf("https://anilist.co/anime/%d", anime.AnilistId) CurdOut(fmt.Sprintf("Opening %s", url)) if err := browser.OpenURL(url); err != nil { Log(fmt.Sprintf("Error opening browser: %v", err)) CurdOut("Failed to open browser.") } } return } sequel := &sequels[0] // Get sequel title based on user's language preference sequelTitle := sequel.Title.Romaji if sequel.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" { sequelTitle = sequel.Title.English } Log(fmt.Sprintf("Found sequel: %s (ID: %d)", sequelTitle, sequel.ID)) // Check if sequel is not yet released if sequel.Status == "NOT_YET_RELEASED" { CurdOut(fmt.Sprintf("A sequel '%s' is announced but not yet released.", sequelTitle)) return } // Fetch user's anime list to check if sequel is already there userId, _, err := GetAnilistUserID(userToken) if err != nil { Log(fmt.Sprintf("Error getting user ID: %v", err)) return } userData, err := GetUserData(userToken, userId) if err != nil { Log(fmt.Sprintf("Error getting user data: %v", err)) return } // Check if userData is valid before parsing if userData == nil || userData["data"] == nil { Log("User data is nil or malformed, skipping sequel list check") // Still show the sequel prompt, but assume it's not in any list promptSequelNotInList(sequel, sequelTitle, userToken, anime) return } userAnimeList := ParseAnimeList(userData) sequelStatus, isInList := FindSequelInAnimeList(userAnimeList, sequel.ID) if !isInList { // Sequel is not in any list - ask if user wants to add it CurdOut(fmt.Sprintf("A sequel is available: %s", sequelTitle)) options := []SelectionOption{ {Key: "watching", Label: "Add to Watching list"}, {Key: "planning", Label: "Add to Plan to Watch"}, {Key: "skip", Label: "No thanks"}, } selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in sequel prompt: %v", err)) return } switch selectedOption.Key { case "watching": err = AddAnimeToList(sequel.ID, "CURRENT", userToken) if err != nil { Log(fmt.Sprintf("Error adding sequel to watching list: %v", err)) CurdOut("Failed to add sequel to watching list") } else { CurdOut(fmt.Sprintf("Added '%s' to your Watching list!", sequelTitle)) } case "planning": err = AddAnimeToList(sequel.ID, "PLANNING", userToken) if err != nil { Log(fmt.Sprintf("Error adding sequel to planning list: %v", err)) CurdOut("Failed to add sequel to Plan to Watch") } else { CurdOut(fmt.Sprintf("Added '%s' to your Plan to Watch list!", sequelTitle)) } case "skip", "-1": Log("User declined to add sequel") } } else { // Sequel is already in a list switch sequelStatus { case "CURRENT": // Already in watching list CurdOut(fmt.Sprintf("The sequel '%s' is already in your Watching list!", sequelTitle)) case "PLANNING": // In planning list - ask if user wants to move to watching CurdOut(fmt.Sprintf("The sequel '%s' is in your Plan to Watch list. Move to Watching?", sequelTitle)) options := []SelectionOption{ {Key: "yes", Label: "Yes, move to Watching list"}, {Key: "no", Label: "No, keep in Plan to Watch"}, } selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in sequel planning prompt: %v", err)) return } if selectedOption.Key == "yes" { err = AddAnimeToList(sequel.ID, "CURRENT", userToken) if err != nil { Log(fmt.Sprintf("Error moving sequel to watching list: %v", err)) CurdOut("Failed to move sequel to Watching list") } else { CurdOut(fmt.Sprintf("Moved '%s' to Watching list!", sequelTitle)) } } case "COMPLETED": CurdOut(fmt.Sprintf("You've already completed the sequel '%s'!", sequelTitle)) case "PAUSED", "DROPPED": CurdOut(fmt.Sprintf("The sequel '%s' is in your %s list.", sequelTitle, sequelStatus)) options := []SelectionOption{ {Key: "yes", Label: "Move to Watching list"}, {Key: "no", Label: "No thanks"}, } selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in sequel resume prompt: %v", err)) return } if selectedOption.Key == "yes" { err = AddAnimeToList(sequel.ID, "CURRENT", userToken) if err != nil { Log(fmt.Sprintf("Error moving sequel to watching list: %v", err)) CurdOut("Failed to move sequel to Watching list") } else { CurdOut(fmt.Sprintf("Moved '%s' to Watching list!", sequelTitle)) } } } } } // promptSequelNotInList prompts user to add sequel when we can't check their list func promptSequelNotInList(sequel *SequelInfo, sequelTitle string, userToken string, anime *Anime) { CurdOut(fmt.Sprintf("A sequel is available: %s", sequelTitle)) options := []SelectionOption{ {Key: "watching", Label: "Add to Watching list"}, {Key: "planning", Label: "Add to Plan to Watch"}, {Key: "skip", Label: "No thanks"}, } selectedOption, err := DynamicSelect(options) if err != nil { Log(fmt.Sprintf("Error in sequel prompt: %v", err)) return } switch selectedOption.Key { case "watching": err = AddAnimeToList(sequel.ID, "CURRENT", userToken) if err != nil { Log(fmt.Sprintf("Error adding sequel to watching list: %v", err)) CurdOut("Failed to add sequel to watching list") } else { CurdOut(fmt.Sprintf("Added '%s' to your Watching list!", sequelTitle)) } case "planning": err = AddAnimeToList(sequel.ID, "PLANNING", userToken) if err != nil { Log(fmt.Sprintf("Error adding sequel to planning list: %v", err)) CurdOut("Failed to add sequel to Plan to Watch") } else { CurdOut(fmt.Sprintf("Added '%s' to your Plan to Watch list!", sequelTitle)) } case "skip", "-1": Log("User declined to add sequel") } } // ChangeProvider allows the user to switch the anime provider func ChangeProvider(userCurdConfig *CurdConfig) { options := []SelectionOption{ {Key: "allanime", Label: "allanime"}, {Key: "animepahe", Label: "animepahe"}, } selected, err := DynamicSelect(options) if err != nil || selected.Key == "-1" || selected.Key == "-2" { return } // Update the config userCurdConfig.Provider = selected.Key CurrentProvider = nil // reset the provider instance // Save to config file configPath := GlobalConfigPath configMap, err := LoadConfigFromFile(configPath) if err == nil { configMap["Provider"] = selected.Key SaveConfigToFile(configPath, configMap) } CurdOut(fmt.Sprintf("\nProvider successfully changed to %s.\n", selected.Label)) time.Sleep(1 * time.Second) } ================================================ FILE: internal/discord.go ================================================ package internal import ( "fmt" "github.com/tr1xem/go-discordrpc/client" "time" ) var discordClient *client.Client var isLoggedIn bool var lastPausedState bool var lastEpisodeNumber int var lastAnimeTitle string var lastUpdateTime time.Time var lastForceUpdateTime time.Time func LoginClient(clientId string) error { if discordClient != nil && isLoggedIn { return nil // Already logged in } discordClient = client.NewClient(clientId) if err := discordClient.Login(); err != nil { return fmt.Errorf("login failed: %w", err) } isLoggedIn = true return nil } func DiscordPresence(anime Anime, IsPaused bool, currentPosition int, totalDuration int, clientId string) error { return DiscordPresenceWithForce(anime, IsPaused, currentPosition, totalDuration, clientId, false) } func DiscordPresenceWithForce(anime Anime, IsPaused bool, currentPosition int, totalDuration int, clientId string, forceUpdate bool) error { // Ensure client is logged in if discordClient == nil || !isLoggedIn { if err := LoginClient(clientId); err != nil { return err } } currentAnimeTitle := GetAnimeName(anime) now := time.Now() shouldUpdate := false if lastForceUpdateTime.IsZero() || time.Since(lastForceUpdateTime) >= 2*time.Minute { shouldUpdate = true lastForceUpdateTime = now } if lastUpdateTime.IsZero() || lastPausedState != IsPaused || lastEpisodeNumber != anime.Ep.Number || lastAnimeTitle != currentAnimeTitle || forceUpdate { shouldUpdate = true } if !shouldUpdate { return nil // Skip update } var timestamps *client.Timestamps var SmallImage = "pause-button" var SmallText = "pause-button" startTime := now.Add(-time.Duration(currentPosition) * time.Second) if IsPaused { timestamps = &client.Timestamps{ Start: &startTime, End: nil, // No end time when paused } SmallImage = "pause-button" SmallText = "Paused" } else { if totalDuration > 60 && totalDuration > currentPosition { remainingSeconds := totalDuration - currentPosition endTime := now.Add(time.Duration(remainingSeconds) * time.Second) timestamps = &client.Timestamps{ Start: &startTime, End: &endTime, } } else { // Duration unknown, show elapsed time only timestamps = &client.Timestamps{ Start: &startTime, End: nil, } } SmallImage = "" SmallText = "" } largeImage := anime.CoverImage if largeImage == "" { largeImage = "https://anilist.co/img/icons/icon.svg" // fallback image } err := discordClient.SetActivity(client.Activity{ Type: 3, // Watching Name: currentAnimeTitle, Details: currentAnimeTitle, // Large text LargeImage: largeImage, LargeText: currentAnimeTitle, // Would display while hovering over the large image State: fmt.Sprintf("Episode %d", anime.Ep.Number), SmallImage: SmallImage, SmallText: SmallText, Timestamps: timestamps, Buttons: []*client.Button{ { Label: "View on AniList", // Button label Url: fmt.Sprintf("https://anilist.co/anime/%d", anime.AnilistId), // Button link }, { Label: "View on MAL", // Button label Url: fmt.Sprintf("https://myanimelist.net/anime/%d", anime.MalId), // Button link }, }, }) if err != nil { return fmt.Errorf("failed to set Discord activity: %w", err) } lastPausedState = IsPaused lastEpisodeNumber = anime.Ep.Number lastAnimeTitle = currentAnimeTitle lastUpdateTime = now // fmt.Println("Discord presence updated!", time.Now()) return nil } func LogoutClient() error { if discordClient != nil && isLoggedIn { if err := discordClient.Logout(); err != nil { return fmt.Errorf("logout failed: %w", err) } isLoggedIn = false discordClient = nil // fmt.Println("Discord RPC logged out!") } return nil } func FormatTime(seconds int) string { hours := seconds / 3600 minutes := (seconds % 3600) / 60 remainingSeconds := seconds % 60 if hours > 0 { return fmt.Sprintf("%d:%02d:%02d", hours, minutes, remainingSeconds) } return fmt.Sprintf("%d:%02d", minutes, remainingSeconds) } func ConvertSecondsToMinutes(seconds int) int { return seconds / 60 } ================================================ FILE: internal/episode_list.go ================================================ package internal import ( "bytes" "encoding/json" "fmt" "io" "net/http" "sort" "strconv" ) type episodesResponse struct { Data struct { Show struct { ID string `json:"_id"` AvailableEpisodesDetail map[string]interface{} `json:"availableEpisodesDetail"` } `json:"show"` } `json:"data"` } // func main() { // // Get environment variables // // Read the ID from the file // id := "ReooPAxPMsHM4KPMY" // // Fetch episodes list // episodeList := episodesList(string(id), "sub") // // Write the episode list to a file // fmt.Println(episodeList) // } // episodesList performs the API call and fetches the episodes list func getAllAnimeEpisodesList(showID, mode string) ([]string, error) { preferredMode := normalizeTranslationType(mode) episodesListGql := `query ($showId String!) { show( _id: $showId ) { _id availableEpisodesDetail }}` // Build POST request body requestBody, err := json.Marshal(map[string]interface{}{ "query": episodesListGql, "variables": map[string]string{"showId": showID}, }) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } // Make the HTTP POST request req, err := http.NewRequest("POST", "https://api.allanime.day/api", bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Referer", "https://allanime.to") req.Header.Set("Origin", "https://allanime.to") resp, err := sharedHTTPClient.Do(req) if err != nil { Log(fmt.Sprint("Error making HTTP request:", err)) return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { Log(fmt.Sprint("Error reading response body:", err)) return nil, err } // Parse the JSON response var response episodesResponse err = json.Unmarshal(body, &response) if err != nil { Log(fmt.Sprint("Error parsing JSON:", err)) return nil, err } // Extract and sort the episodes episodes := extractEpisodes(response.Data.Show.AvailableEpisodesDetail, preferredMode) if len(episodes) == 0 { fallbackMode := alternateTranslationType(preferredMode) episodes = extractEpisodes(response.Data.Show.AvailableEpisodesDetail, fallbackMode) if len(episodes) > 0 { Log(fmt.Sprintf("Falling back to %s episode list for anime %s", fallbackMode, showID)) } } if len(episodes) == 0 { return episodes, fmt.Errorf("no episodes found for anime %s", showID) } return episodes, nil } // extractEpisodes extracts the episodes list from the availableEpisodesDetail field func extractEpisodes(availableEpisodesDetail map[string]interface{}, mode string) []string { var episodes []float64 // Check if the mode (e.g., "sub") exists in the map if eps, ok := availableEpisodesDetail[mode].([]interface{}); ok { for _, ep := range eps { if epNum, err := strconv.ParseFloat(fmt.Sprintf("%v", ep), 64); err == nil { episodes = append(episodes, epNum) } } } // Sort episodes numerically sort.Float64s(episodes) // Convert to string and return var episodesStr []string for _, ep := range episodes { episodesStr = append(episodesStr, fmt.Sprintf("%v", ep)) } return episodesStr } ================================================ FILE: internal/episode_url.go ================================================ package internal import ( "bytes" "crypto/aes" "crypto/cipher" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "sort" "strconv" "strings" "time" "unicode" ) type allanimeResponse struct { Data struct { M string `json:"_m"` Tobeparsed string `json:"tobeparsed"` Episode struct { SourceUrls []struct { SourceUrl string `json:"sourceUrl"` SourceName string `json:"sourceName"` } `json:"sourceUrls"` } `json:"episode"` } `json:"data"` } type result struct { index int links []string err error } type filemoonResponse struct { IV string `json:"iv"` Payload string `json:"payload"` KeyParts []string `json:"key_parts"` } func decodeTobeparsed(blob string) string { key := []byte("Xot36i3lK3:v1") hash := sha256.Sum256(key) data, err := base64.StdEncoding.DecodeString(blob) if err != nil { Log(fmt.Sprint("Error decoding base64:", err)) return "" } if len(data) < 29 { Log("Data too short to contain tobeparsed payload") return "" } // The payload format is: 1-byte header, 12-byte IV, ciphertext, 16-byte trailer. iv := data[1:13] ctLen := len(data) - 13 - 16 if ctLen <= 0 { Log("Ciphertext length is invalid in tobeparsed payload") return "" } ct := data[13 : 13+ctLen] ctrIV := make([]byte, 16) copy(ctrIV, iv) binary.BigEndian.PutUint32(ctrIV[12:], uint32(2)) block, err := aes.NewCipher(hash[:]) if err != nil { Log(fmt.Sprint("Error creating cipher:", err)) return "" } stream := cipher.NewCTR(block, ctrIV) plain := make([]byte, len(ct)) stream.XORKeyStream(plain, ct) result := string(plain) result = strings.ReplaceAll(result, "{", "\n") result = strings.ReplaceAll(result, "}", "\n") re := regexp.MustCompile(`"sourceUrl":"--([^"]+)".*"sourceName":"([^"]+)"`) matches := re.FindAllStringSubmatch(result, -1) var sb strings.Builder for _, match := range matches { if len(match) == 3 { sb.WriteString(match[2]) sb.WriteString(" :") sb.WriteString(match[1]) sb.WriteString("\n") } } return sb.String() } func decodeProviderID(encoded string) string { // Split the string into pairs of characters (.. equivalent of 'sed s/../&\n/g') re := regexp.MustCompile("..") pairs := re.FindAllString(encoded, -1) // Mapping for the replacements replacements := map[string]string{ // Uppercase letters "79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E", "7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J", "73": "K", "74": "L", "75": "M", "76": "N", "77": "O", "68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T", "6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y", "62": "Z", // Lowercase letters "59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e", "5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j", "53": "k", "54": "l", "55": "m", "56": "n", "57": "o", "48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t", "4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y", "42": "z", // Numbers "08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4", "0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9", // Special characters "15": "-", "16": ".", "67": "_", "46": "~", "02": ":", "17": "/", "07": "?", "1b": "#", "63": "[", "65": "]", "78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(", "11": ")", "12": "*", "13": "+", "14": ",", "03": ";", "05": "=", "1d": "%", } // Perform the replacement equivalent to sed 's/^../.../' for i, pair := range pairs { if val, exists := replacements[pair]; exists { pairs[i] = val } } // Join the modified pairs back into a single string result := strings.Join(pairs, "") // Replace "/clock" with "/clock.json" equivalent of sed "s/\/clock/\/clock\.json/" result = strings.ReplaceAll(result, "/clock", "/clock.json") // Print the final result return result } func extractLinks(provider_id string) map[string]interface{} { provider_id = normalizeAllanimeProviderPath(provider_id) // Check if provider_id is already a full URL (external link) if strings.HasPrefix(provider_id, "http://") || strings.HasPrefix(provider_id, "https://") { // It's an external direct video link, preserve it exactly as provided. Log(fmt.Sprintf("Direct external link detected: %s", provider_id)) return map[string]interface{}{ "links": []interface{}{ map[string]interface{}{ "link": provider_id, }, }, } } // It's a relative path for allanime API allanime_base := "https://allanime.day" url := allanime_base + provider_id req, err := http.NewRequest("GET", url, nil) var videoData map[string]interface{} if err != nil { Log(fmt.Sprint("Error creating request:", err)) return videoData } // Add the headers req.Header.Set("Referer", "https://allanime.to") req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0") // Send the request resp, err := sharedHTTPClient.Do(req) if err != nil { Log(fmt.Sprint("Error sending request:", err)) return videoData } defer resp.Body.Close() // Read the response body body, err := io.ReadAll(resp.Body) if err != nil { Log(fmt.Sprint("Error reading response:", err)) return videoData } // Parse the JSON response err = json.Unmarshal(body, &videoData) if err != nil { Log(fmt.Sprint("Error parsing JSON:", err)) return videoData } // Filemoon extractor payload does not return a top-level "links" field. if _, hasLinks := videoData["links"]; !hasLinks { if filemoonLinks := extractFilemoonLinks(videoData); len(filemoonLinks) > 0 { links := make([]interface{}, 0, len(filemoonLinks)) for _, link := range filemoonLinks { links = append(links, map[string]interface{}{"link": link}) } videoData["links"] = links } } // Process the data as needed return videoData } func normalizeAllanimeProviderPath(providerID string) string { const allanimePrefix = "/https://allanime.day" if strings.HasPrefix(providerID, allanimePrefix) { trimmed := strings.TrimPrefix(providerID, allanimePrefix) if trimmed == "" { return "/" } if !strings.HasPrefix(trimmed, "/") { return "/" + trimmed } return trimmed } return providerID } func decodeBase64URLRaw(input string) ([]byte, error) { if decoded, err := base64.RawURLEncoding.DecodeString(input); err == nil { return decoded, nil } return base64.URLEncoding.DecodeString(input) } func extractFilemoonLinks(videoData map[string]interface{}) []string { raw, err := json.Marshal(videoData) if err != nil { Log(fmt.Sprintf("Error marshaling filemoon payload: %v", err)) return nil } var payload filemoonResponse if err := json.Unmarshal(raw, &payload); err != nil { Log(fmt.Sprintf("Error parsing filemoon payload: %v", err)) return nil } if payload.IV == "" || payload.Payload == "" || len(payload.KeyParts) < 2 { return nil } keyPart1, err := decodeBase64URLRaw(payload.KeyParts[0]) if err != nil { Log(fmt.Sprintf("Error decoding filemoon key part 1: %v", err)) return nil } keyPart2, err := decodeBase64URLRaw(payload.KeyParts[1]) if err != nil { Log(fmt.Sprintf("Error decoding filemoon key part 2: %v", err)) return nil } iv, err := decodeBase64URLRaw(payload.IV) if err != nil { Log(fmt.Sprintf("Error decoding filemoon IV: %v", err)) return nil } ciphertext, err := decodeBase64URLRaw(payload.Payload) if err != nil { Log(fmt.Sprintf("Error decoding filemoon ciphertext: %v", err)) return nil } if len(ciphertext) <= 16 { Log("Filemoon ciphertext is too short") return nil } // Match jerry.sh behavior: decrypt all bytes except the final 16-byte trailer. ciphertext = ciphertext[:len(ciphertext)-16] keyHex := hex.EncodeToString(append(keyPart1, keyPart2...)) key, err := hex.DecodeString(keyHex) if err != nil { Log(fmt.Sprintf("Error decoding filemoon key hex: %v", err)) return nil } ctrIVHex := hex.EncodeToString(iv) + "00000002" ctrIV, err := hex.DecodeString(ctrIVHex) if err != nil { Log(fmt.Sprintf("Error decoding filemoon CTR IV: %v", err)) return nil } if len(ctrIV) != aes.BlockSize { Log(fmt.Sprintf("Invalid filemoon CTR IV size: %d", len(ctrIV))) return nil } block, err := aes.NewCipher(key) if err != nil { Log(fmt.Sprintf("Error creating filemoon cipher: %v", err)) return nil } plain := make([]byte, len(ciphertext)) cipher.NewCTR(block, ctrIV).XORKeyStream(plain, ciphertext) decoded := strings.ReplaceAll(string(plain), `\u0026`, "&") decoded = strings.ReplaceAll(decoded, `\u003D`, "=") type qualityLink struct { height int link string } collected := make([]qualityLink, 0) seen := make(map[string]struct{}) reURLFirst := regexp.MustCompile(`"url":"([^"]+)".*?"height":([0-9]+)`) reHeightFirst := regexp.MustCompile(`"height":([0-9]+).*?"url":"([^"]+)"`) for _, match := range reURLFirst.FindAllStringSubmatch(decoded, -1) { if len(match) != 3 { continue } height, err := strconv.Atoi(match[2]) if err != nil { continue } if _, exists := seen[match[1]]; exists { continue } seen[match[1]] = struct{}{} collected = append(collected, qualityLink{height: height, link: match[1]}) } for _, match := range reHeightFirst.FindAllStringSubmatch(decoded, -1) { if len(match) != 3 { continue } height, err := strconv.Atoi(match[1]) if err != nil { continue } if _, exists := seen[match[2]]; exists { continue } seen[match[2]] = struct{}{} collected = append(collected, qualityLink{height: height, link: match[2]}) } sort.Slice(collected, func(i, j int) bool { return collected[i].height > collected[j].height }) links := make([]string, 0, len(collected)) for _, entry := range collected { links = append(links, entry.link) } if len(links) > 0 { Log("Filemoon links fetched") } return links } // Get anime episode url respective to given config // If the link is found, it returns a list of links. Otherwise, it returns an error. // // Parameters: // - config: Configuration of the anime search. // - id: Allanime id of the anime to search for. // - epNo: Anime episode number to get links for. // // Returns: // - []string: a list of links for specified episode. // - error: an error if the episode is not found or if there is an issue during the search. func getAllanimeEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) { preferredMode := normalizeTranslationType(config.SubOrDub) fallbackMode := alternateTranslationType(preferredMode) type modeResult struct { mode string links []string err error } ch := make(chan modeResult, 2) go func() { links, err := getEpisodeURLForMode(id, preferredMode, epNo) ch <- modeResult{mode: preferredMode, links: links, err: err} }() go func() { links, err := getEpisodeURLForMode(id, fallbackMode, epNo) ch <- modeResult{mode: fallbackMode, links: links, err: err} }() var preferredRes, fallbackRes modeResult hasPreferredRes := false hasFallbackRes := false for i := 0; i < 2; i++ { res := <-ch if res.mode == preferredMode { preferredRes = res hasPreferredRes = true if res.err == nil && len(res.links) > 0 { return res.links, nil } continue } if res.mode == fallbackMode { fallbackRes = res hasFallbackRes = true } } if hasPreferredRes && preferredRes.err == nil && len(preferredRes.links) > 0 { return preferredRes.links, nil } if hasFallbackRes && fallbackRes.err == nil && len(fallbackRes.links) > 0 { Log(fmt.Sprintf("Falling back to %s for anime %s episode %d", fallbackMode, id, epNo)) return fallbackRes.links, nil } if hasPreferredRes && preferredRes.err != nil { return nil, preferredRes.err } if hasFallbackRes && fallbackRes.err != nil { return nil, fallbackRes.err } return nil, fmt.Errorf("no valid links found for anime %s episode %d", id, epNo) } func getEpisodeURLForMode(id, mode string, epNo int) ([]string, error) { const ( episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec" ) episodeEmbedGQL := `query ($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) { episode( showId: $showId translationType: $translationType episodeString: $episodeString ) { episodeString sourceUrls }}` variables := map[string]interface{}{ "showId": id, "translationType": normalizeTranslationType(mode), "episodeString": fmt.Sprintf("%d", epNo), } extensions := map[string]interface{}{ "persistedQuery": map[string]interface{}{ "version": 1, "sha256Hash": episodeQueryHash, }, } variablesJSON, err := json.Marshal(variables) if err != nil { return nil, fmt.Errorf("failed to marshal persisted query variables: %w", err) } extensionsJSON, err := json.Marshal(extensions) if err != nil { return nil, fmt.Errorf("failed to marshal persisted query extensions: %w", err) } persistedURL := "https://api.allanime.day/api?variables=" + url.QueryEscape(string(variablesJSON)) + "&extensions=" + url.QueryEscape(string(extensionsJSON)) req, err := http.NewRequest("GET", persistedURL, nil) if err != nil { return nil, fmt.Errorf("failed to create persisted query request: %w", err) } req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0") req.Header.Set("Referer", "https://allmanga.to") req.Header.Set("Origin", "https://youtu-chan.com") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send persisted query request: %w", err) } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, fmt.Errorf("failed to read persisted query response: %w", err) } var response allanimeResponse if err := json.Unmarshal(body, &response); err != nil { Log(fmt.Sprint("Error parsing persisted query JSON: ", err)) } useFallback := response.Data.Tobeparsed == "" && len(response.Data.Episode.SourceUrls) == 0 if useFallback { query := episodeEmbedGQL // Build POST request body requestBody, err := json.Marshal(map[string]interface{}{ "query": query, "variables": variables, }) if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } req, err := http.NewRequest("POST", "https://api.allanime.day/api", bytes.NewBuffer(requestBody)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "Mozilla/5.0") req.Header.Set("Referer", "https://allmanga.to") req.Header.Set("Origin", "https://allanime.to") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if err := json.Unmarshal(body, &response); err != nil { Log(fmt.Sprint("Error parsing fallback JSON: ", err)) return nil, err } } if response.Data.Tobeparsed != "" { Log("Found tobeparsed field, using decoded response") decoded := decodeTobeparsed(response.Data.Tobeparsed) lines := strings.Split(strings.TrimSpace(decoded), "\n") var parsedURLs []struct { SourceName string SourceUrl string } for _, line := range lines { if parts := strings.Split(line, " :"); len(parts) == 2 { parsedURLs = append(parsedURLs, struct { SourceName string SourceUrl string }{SourceName: parts[0], SourceUrl: "--" + parts[1]}) } } validURLs := make([]string, 0) for _, url := range parsedURLs { validURLs = append(validURLs, url.SourceUrl) } if len(validURLs) == 0 { return nil, fmt.Errorf("no valid source URLs found in decoded tobeparsed") } return getLinksFromURLs(validURLs) } return getLinksFromSourceUrls(response.Data.Episode.SourceUrls) } func getLinksFromSourceUrls(sourceUrls []struct { SourceUrl string `json:"sourceUrl"` SourceName string `json:"sourceName"` }) ([]string, error) { validURLs := make([]string, 0) highestPriority := -1 var highestPriorityURL string for _, url := range sourceUrls { if len(url.SourceUrl) > 2 && unicode.IsDigit(rune(url.SourceUrl[2])) { decodedURL := decodeProviderID(url.SourceUrl[2:]) if strings.Contains(decodedURL, LinkPriorities[0]) { priority := int(url.SourceUrl[2] - '0') if priority > highestPriority { highestPriority = priority highestPriorityURL = url.SourceUrl } } else { validURLs = append(validURLs, url.SourceUrl) } } } if highestPriorityURL != "" { validURLs = []string{highestPriorityURL} } if len(validURLs) == 0 { return nil, fmt.Errorf("no valid source URLs found in response") } return getLinksFromURLs(validURLs) } func getLinksFromURLs(validURLs []string) ([]string, error) { results := make(chan result, len(validURLs)) orderedResults := make([][]string, len(validURLs)) highPriorityLink := make(chan []string, 1) remainingURLs := len(validURLs) for i, sourceUrl := range validURLs { go func(idx int, url string) { decodedProviderID := decodeProviderID(url[2:]) Log(fmt.Sprintf("Processing URL %d/%d with provider ID: %s", idx+1, len(validURLs), decodedProviderID)) extractedLinks := extractLinks(decodedProviderID) if extractedLinks == nil { results <- result{ index: idx, err: fmt.Errorf("failed to extract links for provider %s", decodedProviderID), } return } linksInterface, ok := extractedLinks["links"].([]interface{}) if !ok { results <- result{ index: idx, err: fmt.Errorf("links field is not []interface{} for provider %s", decodedProviderID), } return } var links []string for _, linkInterface := range linksInterface { linkMap, ok := linkInterface.(map[string]interface{}) if !ok { Log(fmt.Sprintf("Warning: invalid link format for provider %s", decodedProviderID)) continue } link, ok := linkMap["link"].(string) if !ok { Log(fmt.Sprintf("Warning: link field is not string for provider %s", decodedProviderID)) continue } links = append(links, link) } // Check if any of the extracted links are high priority for _, link := range links { for _, domain := range LinkPriorities[:3] { if strings.Contains(link, domain) { // Found high priority link, send it immediately select { case highPriorityLink <- []string{link}: default: // Channel already has a high priority link } break } } } results <- result{ index: idx, links: links, } }(i, sourceUrl) } // Collect results with timeout timeout := time.After(10 * time.Second) var collectedErrors []error successCount := 0 // First, try to get a high priority link select { case links := <-highPriorityLink: // Continue extracting other links in background go collectRemainingResults(results, orderedResults, &successCount, &collectedErrors, remainingURLs) return links, nil case <-time.After(2 * time.Second): // Wait only briefly for high priority link // No high priority link found quickly, proceed with normal collection } // Continue with existing result collection logic // Collect results maintaining order for successCount < len(validURLs) { select { case res := <-results: if res.err != nil { Log(fmt.Sprintf("Error processing URL %d: %v", res.index+1, res.err)) collectedErrors = append(collectedErrors, fmt.Errorf("URL %d: %w", res.index+1, res.err)) } else { orderedResults[res.index] = res.links successCount++ Log(fmt.Sprintf("Successfully processed URL %d/%d", res.index+1, len(validURLs))) } case <-timeout: if successCount > 0 { Log(fmt.Sprintf("Timeout reached with %d/%d successful results", successCount, len(validURLs))) // Flatten available results return flattenResults(orderedResults), nil } return nil, fmt.Errorf("timeout waiting for results after %d successful responses", successCount) } } // If we have any errors but also some successes, log errors but continue if len(collectedErrors) > 0 { Log(fmt.Sprintf("Completed with %d errors: %v", len(collectedErrors), collectedErrors)) } // Flatten and return results allLinks := flattenResults(orderedResults) if len(allLinks) == 0 { return nil, fmt.Errorf("no valid links found from %d URLs: %v", len(validURLs), collectedErrors) } return allLinks, nil } // Helper function to collect remaining results in background func collectRemainingResults(results chan result, orderedResults [][]string, successCount *int, collectedErrors *[]error, remainingURLs int) { for *successCount < remainingURLs { select { case res := <-results: if res.err != nil { Log(fmt.Sprintf("Error processing URL %d: %v", res.index+1, res.err)) *collectedErrors = append(*collectedErrors, fmt.Errorf("URL %d: %w", res.index+1, res.err)) } else { orderedResults[res.index] = res.links *successCount++ Log(fmt.Sprintf("Successfully processed URL %d/%d", res.index+1, remainingURLs)) } case <-time.After(10 * time.Second): return } } } // converts the ordered slice of link slices into a single slice func flattenResults(results [][]string) []string { var totalLen int for _, r := range results { totalLen += len(r) } allLinks := make([]string, 0, totalLen) for _, links := range results { allLinks = append(allLinks, links...) } return allLinks } ================================================ FILE: internal/filler_list.go ================================================ package internal import ( "encoding/json" "fmt" "net/http" "time" ) type AnimeFillerListEpisode struct { EpisodeID int `json:"mal_id"` IsFiller bool `json:"filler"` } type EpisodesResponse struct { Data []AnimeFillerListEpisode `json:"data"` Pagination struct { HasNextPage bool `json:"has_next_page"` } `json:"pagination"` } func FetchFillerEpisodes(malID int) ([]int, error) { baseURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes", malID) var fillerEpisodes []int page := 1 rateLimiter := time.NewTicker(334 * time.Millisecond) // ~3 requests per second defer rateLimiter.Stop() for { <-rateLimiter.C // Wait for rate limit url := fmt.Sprintf("%s?page=%d", baseURL, page) resp, err := http.Get(url) if err != nil { return nil, fmt.Errorf("error fetching episodes: %v", err) } if resp.StatusCode == http.StatusTooManyRequests { // Wait for 2 seconds and retry time.Sleep(2 * time.Second) continue } if resp.StatusCode != http.StatusOK { resp.Body.Close() return nil, fmt.Errorf("received non-200 response: %d", resp.StatusCode) } var episodesResp EpisodesResponse if err := json.NewDecoder(resp.Body).Decode(&episodesResp); err != nil { resp.Body.Close() return nil, fmt.Errorf("error decoding response: %v", err) } resp.Body.Close() for _, episode := range episodesResp.Data { if episode.IsFiller { fillerEpisodes = append(fillerEpisodes, episode.EpisodeID) } } if !episodesResp.Pagination.HasNextPage { break } page++ } return fillerEpisodes, nil } // IsEpisodeFiller checks if a given episode number is in the filler episodes list func IsEpisodeFiller(fillerEpisodes []int, episodeNumber int) bool { for _, fillerEp := range fillerEpisodes { if fillerEp == episodeNumber { return true } } return false } // GetNextCanonEpisode returns the next non-filler episode number after the current episode func GetNextCanonEpisode(fillerEpisodes []int, currentEpisode int) int { nextEpisode := currentEpisode + 1 // Keep incrementing episode number until we find a non-filler episode for IsEpisodeFiller(fillerEpisodes, nextEpisode) { nextEpisode++ } return nextEpisode } ================================================ FILE: internal/globals.go ================================================ package internal var ( globalAnime *Anime globalLogFile string ) // SetGlobalAnime sets the global anime reference func SetGlobalAnime(anime *Anime) { globalAnime = anime } // GetGlobalAnime gets the global anime reference func GetGlobalAnime() *Anime { return globalAnime } // SetGlobalLogFile sets the global log file path func SetGlobalLogFile(logFile string) { globalLogFile = logFile } // GetGlobalLogFile gets the global log file path func GetGlobalLogFile() string { return globalLogFile } ================================================ FILE: internal/http_client.go ================================================ package internal import ( "net/http" "net/http/cookiejar" "net/url" "time" ) var sharedHTTPClient *http.Client func SetCookiesForAnimepahe(u *url.URL, cookies []*http.Cookie) { if sharedHTTPClient != nil && sharedHTTPClient.Jar != nil { sharedHTTPClient.Jar.SetCookies(u, cookies) } } func init() { jar, _ := cookiejar.New(nil) sharedHTTPClient = &http.Client{ Transport: &http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 5, IdleConnTimeout: 30 * time.Second, }, Timeout: 15 * time.Second, Jar: jar, } } ================================================ FILE: internal/jikan.go ================================================ package internal import ( "encoding/json" "fmt" "io" "net/http" ) // GetEpisodeData fetches episode data for a given anime ID and episode number func GetEpisodeData(animeID int, episodeNo int, anime *Anime) error { url := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/episodes/%d", animeID, episodeNo) // Use the helper function for making the GET request response, err := makeGetRequest(url, nil) if err != nil { Log(fmt.Sprintf("Warning: Jikan API error: %v - continuing without filler data", err)) // Set default values when API fails anime.Ep.IsFiller = false anime.Ep.IsRecap = false return nil // Return nil to allow the application to continue } Log(response) // Check if the 'data' field exists and is valid data, ok := response["data"].(map[string]interface{}) if !ok { Log("Warning: Invalid Jikan API response - continuing without filler data") // Set default values when response is invalid anime.Ep.IsFiller = false anime.Ep.IsRecap = false return nil // Return nil to allow the application to continue } // Helper function to safely get string value getStringValue := func(field string) string { if value, ok := data[field].(string); ok { return value } return "" } // Helper function to safely get int value getIntValue := func(field string) int { if value, ok := data[field].(float64); ok { return int(value) } return 0 } // Helper function to safely get bool value getBoolValue := func(field string) bool { if value, ok := data[field].(bool); ok { return value } return false } // Safely assign values to the Anime struct anime.Ep.Title.Romaji = getStringValue("title_romanji") anime.Ep.Title.English = getStringValue("title") anime.Ep.Title.Japanese = getStringValue("title_japanese") anime.Ep.Aired = getStringValue("aired") anime.Ep.Duration = getIntValue("duration") anime.Ep.IsFiller = getBoolValue("filler") anime.Ep.IsRecap = getBoolValue("recap") anime.Ep.Synopsis = getStringValue("synopsis") return nil } // Helper function to make GET requests func makeGetRequest(url string, headers map[string]string) (map[string]interface{}, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { return nil, fmt.Errorf("failed to create GET request: %w", err) } for key, value := range headers { req.Header.Set(key, value) } client := &http.Client{} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to send GET request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read response body: %w", err) } if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed with status %d: %s", resp.StatusCode, body) } var responseData map[string]interface{} err = json.Unmarshal(body, &responseData) if err != nil { return nil, fmt.Errorf("failed to unmarshal response: %w", err) } return responseData, nil } // FetchJikanPictures fetches the pictures for an anime using the Jikan API. // It returns a list of all raw image URLs. func FetchJikanPictures(malID int) ([]string, error) { url := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d/pictures", malID) response, err := makeGetRequest(url, nil) if err != nil { return nil, fmt.Errorf("Jikan API request failed: %v", err) } dataList, ok := response["data"].([]interface{}) if !ok { return nil, fmt.Errorf("invalid Jikan API response format") } var urls []string for _, item := range dataList { if mapItem, ok := item.(map[string]interface{}); ok { for _, format := range []string{"jpg", "webp"} { if formatData, ok := mapItem[format].(map[string]interface{}); ok { if imgURL, ok := formatData["image_url"].(string); ok && imgURL != "" { urls = append(urls, imgURL) } if smallURL, ok := formatData["small_image_url"].(string); ok && smallURL != "" { urls = append(urls, smallURL) } if largeURL, ok := formatData["large_image_url"].(string); ok && largeURL != "" { urls = append(urls, largeURL) } } } } } return urls, nil } type JikanAnimeData struct { MalID int `json:"mal_id"` Title string `json:"title"` TitleEnglish string `json:"title_english"` TitleJapanese string `json:"title_japanese"` Type string `json:"type"` Episodes int `json:"episodes"` Status string `json:"status"` Score float64 `json:"score"` Season string `json:"season"` Year int `json:"year"` } func FetchJikanAnimeData(malID int) (*JikanAnimeData, error) { url := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", malID) response, err := makeGetRequest(url, nil) if err != nil { return nil, fmt.Errorf("Jikan API request failed: %v", err) } dataMap, ok := response["data"].(map[string]interface{}) if !ok { return nil, fmt.Errorf("invalid Jikan API response format") } dataBytes, err := json.Marshal(dataMap) if err != nil { return nil, err } var data JikanAnimeData err = json.Unmarshal(dataBytes, &data) if err != nil { return nil, err } return &data, nil } ================================================ FILE: internal/links.go ================================================ package internal import "strings" // LinkPriorities defines the order of priority for link domains var LinkPriorities = []string{ "sharepoint.com", "wixmp.com", "dropbox.com", "wetransfer.com", "gogoanime.com", // Add more domains in order of priority } // PrioritizeLink takes an array of links and returns a single link based on priority func PrioritizeLink(links []string) string { if len(links) == 0 { return "" } // Create a map for quick lookup of priorities priorityMap := make(map[string]int) for i, p := range LinkPriorities { priorityMap[p] = len(LinkPriorities) - i // Higher index means higher priority } highestPriority := -1 var bestLink string for _, link := range links { for domain, priority := range priorityMap { if strings.Contains(link, domain) { if priority > highestPriority { highestPriority = priority bestLink = link } break } } } // If no priority link found, return the first link if bestLink == "" { return links[0] } return bestLink } ================================================ FILE: internal/localTracking.go ================================================ package internal import ( "bufio" "encoding/csv" "fmt" "os" "path/filepath" "strconv" "strings" "time" ) // Function to add an anime entry func LocalAddAnime(databaseFile string, anilistID int, allanimeID string, watchingEpisode int, watchingTime int, animeDuration int, animeName string) { file, err := os.OpenFile(databaseFile, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) if err != nil { CurdOut(fmt.Sprintf("Error opening file: %v", err)) return } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() err = writer.Write([]string{ strconv.Itoa(anilistID), allanimeID, strconv.Itoa(watchingEpisode), strconv.Itoa(watchingTime), strconv.Itoa(animeDuration), animeName, }) if err != nil { CurdOut(fmt.Sprintf("Error writing to file: %v", err)) } else { CurdOut("Written to file") } } // Function to delete an anime entry by Anilist ID and Allanime ID func LocalDeleteAnime(databaseFile string, anilistID int, allanimeID string) { animeList := [][]string{} file, err := os.Open(databaseFile) if err != nil { CurdOut(fmt.Sprintf("Error opening file: %v", err)) return } defer file.Close() reader := csv.NewReader(file) records, err := reader.ReadAll() if err != nil { CurdOut(fmt.Sprintf("Error reading file: %v", err)) return } // Filter out the anime entry for _, row := range records { aid, _ := strconv.Atoi(row[0]) // Anilist ID if aid != anilistID || row[1] != allanimeID { animeList = append(animeList, row) } } // Write the filtered list back to the file fileWrite, err := os.OpenFile(databaseFile, os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { CurdOut(fmt.Sprintf("Error opening file for writing: %v", err)) return } defer fileWrite.Close() writer := csv.NewWriter(fileWrite) defer writer.Flush() err = writer.WriteAll(animeList) if err != nil { CurdOut(fmt.Sprintf("Error writing to file: %v", err)) } } // Function to get all anime entries from the database func LocalGetAllAnime(databaseFile string) []Anime { animeList := []Anime{} // Ensure the directory exists dir := filepath.Dir(databaseFile) if err := os.MkdirAll(dir, 0755); err != nil { CurdOut(fmt.Sprintf("Error creating directory: %v", err)) return animeList } // Open the file, create if it doesn't exist file, err := os.OpenFile(databaseFile, os.O_RDONLY|os.O_CREATE, 0644) if err != nil { CurdOut(fmt.Sprintf("Error opening or creating file: %v", err)) return animeList } defer file.Close() // If the file was just created, it will be empty, so return an empty list fileInfo, err := file.Stat() if err != nil { CurdOut(fmt.Sprintf("Error getting file info: %v", err)) return animeList } if fileInfo.Size() == 0 { return animeList } reader := csv.NewReader(file) records, err := reader.ReadAll() if err != nil { CurdOut(fmt.Sprintf("Error reading file: %v", err)) return animeList } for _, row := range records { anime := parseAnimeRow(row) if anime != nil { animeList = append(animeList, *anime) } } return animeList } // Function to parse a single row of anime data func parseAnimeRow(row []string) *Anime { if len(row) < 5 { CurdOut(fmt.Sprintf("Invalid row format: %v", row)) return nil } anilistID, _ := strconv.Atoi(row[0]) watchingEpisode, _ := strconv.Atoi(row[2]) playbackTime, _ := strconv.Atoi(row[3]) animeDuration, _ := strconv.Atoi(row[4]) anime := &Anime{ AnilistId: anilistID, ProviderId: row[1], Ep: Episode{ Number: watchingEpisode, Player: playingVideo{ PlaybackTime: playbackTime, }, Duration: animeDuration, }, } if len(row) >= 7 { anime.ProviderName = row[5] anime.Title = AnimeTitle{ English: row[6], Romaji: row[6], } } else if len(row) >= 6 { anime.ProviderName = "allanime" anime.Title = AnimeTitle{ English: row[5], Romaji: row[5], } } else if len(row) == 5 { anime.ProviderName = "allanime" anime.Title = AnimeTitle{ English: row[4], Romaji: row[4], } } return anime } // Function to get the anime name (English or Romaji) from an Anime struct func GetAnimeName(anime Anime) string { userCurdConfig := GetGlobalConfig() if anime.Title.English != "" && userCurdConfig.AnimeNameLanguage == "english" { return anime.Title.English } return anime.Title.Romaji } // Function to update or add a new anime entry func LocalUpdateAnime(databaseFile string, anilistID int, allanimeID string, watchingEpisode int, playbackTime int, animeDuration int, animeName string, providerName string) error { // Read existing entries animeList := LocalGetAllAnime(databaseFile) // Find and update existing entry or add new one updated := false for i, anime := range animeList { if anime.AnilistId == anilistID && anime.ProviderId == allanimeID { animeList[i].Ep.Number = watchingEpisode animeList[i].Ep.Player.PlaybackTime = playbackTime animeList[i].Ep.Duration = animeDuration animeList[i].Title.English = animeName animeList[i].Title.Romaji = animeName animeList[i].ProviderName = providerName updated = true break } } if !updated { newAnime := Anime{ AnilistId: anilistID, ProviderId: allanimeID, Ep: Episode{ Number: watchingEpisode, Player: playingVideo{ PlaybackTime: playbackTime, }, Duration: animeDuration, }, Title: AnimeTitle{ English: animeName, Romaji: animeName, }, ProviderName: providerName, } animeList = append(animeList, newAnime) } // Write updated list back to file file, err := os.Create(databaseFile) if err != nil { CurdOut(fmt.Sprintf("Error creating file: %v", err)) return err } defer file.Close() writer := csv.NewWriter(file) defer writer.Flush() for _, anime := range animeList { record := []string{ strconv.Itoa(anime.AnilistId), anime.ProviderId, strconv.Itoa(anime.Ep.Number), strconv.Itoa(anime.Ep.Player.PlaybackTime), strconv.Itoa(anime.Ep.Duration), anime.ProviderName, GetAnimeName(anime), } if err := writer.Write(record); err != nil { CurdOut(fmt.Sprintf("Error writing record: %v", err)) } } return nil } // Function to find an anime by either Anilist ID or Allanime ID func LocalFindAnime(animeList []Anime, anilistID int, allanimeID string) *Anime { var bestMatch *Anime for i := range animeList { anime := &animeList[i] if anime.AnilistId == anilistID || (allanimeID != "" && anime.ProviderId == allanimeID) { if bestMatch == nil || anime.Ep.Number > bestMatch.Ep.Number || (anime.Ep.Number == bestMatch.Ep.Number && anime.Ep.Player.PlaybackTime > bestMatch.Ep.Player.PlaybackTime) { bestMatch = anime } } } return bestMatch } func WatchUntracked(userCurdConfig *CurdConfig) { var query string var animeList []SelectionOption var err error var anime Anime // Anime search and selection loop for { // Get anime name from user if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Enter the anime name") if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } query = userInput } else { CurdOut("Enter the anime name:") fmt.Scanln(&query) } // Search for the anime animeList, err = SearchAnime(query, userCurdConfig.SubOrDub) if err != nil { Log(fmt.Sprintf("Failed to search anime: %v", err)) ExitCurd(fmt.Errorf("Failed to search anime")) } if len(animeList) == 0 { // Prompt user for manual query for { var manualQuery string if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi(fmt.Sprintf("No results found for '%s'. Press Enter to search with the original name, or enter a custom name to search on AllAnime.", query)) if err != nil { Log("Error getting user input: " + err.Error()) ExitCurd(fmt.Errorf("Error getting user input: " + err.Error())) } manualQuery = userInput } else { CurdOut(fmt.Sprintf("No results found for '%s'.", query)) CurdOut("Press Enter to search with the original name, or enter a custom name to search on AllAnime:") reader := bufio.NewReader(os.Stdin) input, _ := reader.ReadString('\n') manualQuery = strings.TrimSpace(input) } // If empty, use original query name if manualQuery == "" { manualQuery = query } animeList, err = SearchAnime(manualQuery, userCurdConfig.SubOrDub) if err != nil { Log(fmt.Sprintf("Failed to search anime with query '%s': %v", manualQuery, err)) ExitCurd(fmt.Errorf("Failed to search anime")) } if len(animeList) > 0 { break } } } // Select anime from search results selectedAnime, err := DynamicSelect(animeList) if err != nil { Log(fmt.Sprintf("Failed to select anime: %v", err)) ExitCurd(fmt.Errorf("Failed to select anime")) } if selectedAnime.Key == "-1" { ExitCurd(nil) } // Back goes to home menu if selectedAnime.Key == "-2" { return // Return to caller (home menu) } anime.ProviderId = selectedAnime.Key if selectedAnime.Title != "" { anime.Title.English = selectedAnime.Title anime.Title.Romaji = selectedAnime.Title } else { anime.Title.English = selectedAnime.Label anime.Title.Romaji = selectedAnime.Label } break } // Get episode number var episodeNumber int if userCurdConfig.RofiSelection { userInput, err := GetUserInputFromRofi("Enter the episode number") if err != nil { Log("Error getting episode number: " + err.Error()) ExitCurd(fmt.Errorf("Error getting episode number: " + err.Error())) } episodeNumber, err = strconv.Atoi(userInput) if err != nil { Log(fmt.Sprintf("Invalid episode number: %v", err)) ExitCurd(fmt.Errorf("Invalid episode number")) } } else { CurdOut("Enter the episode number:") fmt.Scanln(&episodeNumber) } anime.Ep.Number = episodeNumber for { // Get episode link link, err := GetEpisodeURL(*userCurdConfig, anime.ProviderId, anime.Ep.Number) if err != nil { Log(fmt.Sprintf("Failed to get episode link: %v", err)) ExitCurd(fmt.Errorf("Failed to get episode link")) } if len(link) == 0 { ExitCurd(fmt.Errorf("No episode links found")) } CurdOut(fmt.Sprintf("%s - Episode %d", GetAnimeName(anime), anime.Ep.Number)) // Start video playback mpvSocketPath, err := StartVideo(PrioritizeLink(link), []string{}, fmt.Sprintf("%s - Episode %d", GetAnimeName(anime), anime.Ep.Number), &anime) if err != nil { Log("Failed to start mpv") os.Exit(1) } anime.Ep.Player.SocketPath = mpvSocketPath anime.Ep.Started = false Log(fmt.Sprintf("Started mpv with socket path: %s", anime.Ep.Player.SocketPath)) // Get video duration go func() { for { if anime.Ep.Started { if anime.Ep.Duration == 0 { // Get video duration durationPos, err := MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "duration"}) if err != nil { Log("Error getting video duration: " + err.Error()) } else if durationPos != nil { if duration, ok := durationPos.(float64); ok { anime.Ep.Duration = int(duration + 0.5) // Round to nearest integer Log(fmt.Sprintf("Video duration: %d seconds", anime.Ep.Duration)) } else { Log("Error: duration is not a float64") } } break } } time.Sleep(1 * time.Second) } }() // Listen for video started for { timePos, err := MPVSendCommand(anime.Ep.Player.SocketPath, []interface{}{"get_property", "time-pos"}) if err != nil { Log("Error getting playback time: " + err.Error()) // Check if the error is due to invalid JSON // User closed the video if anime.Ep.Started { percentageWatched := PercentageWatched(anime.Ep.Player.PlaybackTime, anime.Ep.Duration) // Episode is completed Log(fmt.Sprint(percentageWatched)) Log(fmt.Sprint(anime.Ep.Player.PlaybackTime)) Log(fmt.Sprint(anime.Ep.Duration)) Log(fmt.Sprint(userCurdConfig.PercentageToMarkComplete)) if int(percentageWatched) >= userCurdConfig.PercentageToMarkComplete { anime.Ep.Number++ anime.Ep.Started = false Log("Completed episode, starting next.") anime.Ep.IsCompleted = true // Exit the skip loop break } else if fmt.Sprintf("%v", err) == "invalid character '{' after top-level value" { // Episode is not completed Log("Received invalid JSON response, continuing...") } else { Log("Episode is not completed, exiting") ExitCurd(nil) } } } // Convert timePos to integer if timePos != nil { if !anime.Ep.Started { anime.Ep.Started = true } animePosition, ok := timePos.(float64) if !ok { Log("Error: timePos is not a float64") continue } anime.Ep.Player.PlaybackTime = int(animePosition + 0.5) // Round to nearest integer } time.Sleep(1 * time.Second) } } } ================================================ FILE: internal/player.go ================================================ package internal import ( "crypto/rand" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" ) var logFile = "debug.log" // This is not generic but we have MpvArgs in CurdConfig to add custom ones const defaultStreamReferrer = "allanime.day" // We should really handle this by Provider but keeping simple string here for now func getBundledMPVPath() (string, error) { exePath, err := os.Executable() if err != nil { return "", err } exeDir := filepath.Dir(exePath) mpvPath := filepath.Join(exeDir, "bin", "mpv.exe") return mpvPath, nil } func resolveExecutable(binary string) (string, error) { binary = strings.TrimSpace(binary) if binary == "" { return "", fmt.Errorf("empty binary name") } if filepath.IsAbs(binary) || strings.Contains(binary, "/") || strings.Contains(binary, "\\") { if _, err := os.Stat(binary); err == nil { return binary, nil } return "", fmt.Errorf("binary path not found: %s", binary) } resolvedPath, err := exec.LookPath(binary) if err != nil { return "", err } return resolvedPath, nil } func candidatePlayerBinaries(configuredPlayer string) []string { player := strings.TrimSpace(configuredPlayer) if player == "" { player = "mpv" } var candidates []string addUnique := func(value string) { value = strings.TrimSpace(value) if value == "" { return } for _, existing := range candidates { if existing == value { return } } candidates = append(candidates, value) } addUnique(player) if strings.EqualFold(player, "iina") { // iina is mpv-based and may be exposed either on PATH or via app bundle. addUnique("iina") if runtime.GOOS == "darwin" { addUnique("/Applications/IINA.app/Contents/MacOS/IINA") } } return candidates } func resolveMPVBinary() (string, error) { if runtime.GOOS == "windows" { bundledMPVPath, err := getBundledMPVPath() if err == nil { if _, statErr := os.Stat(bundledMPVPath); statErr == nil { return bundledMPVPath, nil } } } return resolveExecutable("mpv") } func resolveConfiguredPlayerBinary(configuredPlayer string) (string, string, error) { configuredPlayer = strings.TrimSpace(configuredPlayer) if configuredPlayer == "" { configuredPlayer = "mpv" } for _, candidate := range candidatePlayerBinaries(configuredPlayer) { resolvedPath, err := resolveExecutable(candidate) if err == nil { return resolvedPath, configuredPlayer, nil } } mpvPath, mpvErr := resolveMPVBinary() if mpvErr != nil { return "", "", fmt.Errorf("configured player %q was not found and fallback to mpv failed: %w", configuredPlayer, mpvErr) } if !strings.EqualFold(configuredPlayer, "mpv") { warning := fmt.Sprintf("Configured player '%s' was not found. Falling back to mpv.", configuredPlayer) CurdOut(warning) Log(warning) } return mpvPath, "mpv", nil } func isIINAPlayer(effectivePlayerName string, resolvedPlayerBinary string) bool { if strings.EqualFold(strings.TrimSpace(effectivePlayerName), "iina") { return true } binaryName := strings.TrimSuffix(filepath.Base(resolvedPlayerBinary), filepath.Ext(resolvedPlayerBinary)) return strings.EqualFold(binaryName, "iina") } func translateMPVArgsForIINA(mpvArgs []string) []string { translated := make([]string, 0, len(mpvArgs)) for _, arg := range mpvArgs { if strings.HasPrefix(arg, "--") { if strings.HasPrefix(arg, "--mpv-") { translated = append(translated, arg) continue } translated = append(translated, "--mpv-"+strings.TrimPrefix(arg, "--")) continue } translated = append(translated, arg) } return translated } func isHTTPStreamLink(link string) bool { trimmedLink := strings.ToLower(strings.TrimSpace(link)) return strings.HasPrefix(trimmedLink, "http://") || strings.HasPrefix(trimmedLink, "https://") } func hasMPVReferrerArg(args []string) bool { normalizeReferrerFlag := func(arg string) string { normalized := strings.ToLower(strings.TrimSpace(arg)) if strings.HasPrefix(normalized, "--mpv-") { return "--" + strings.TrimPrefix(normalized, "--mpv-") } return normalized } for i, arg := range args { normalizedArg := normalizeReferrerFlag(arg) if strings.HasPrefix(normalizedArg, "--referrer=") || normalizedArg == "--referrer" { return true } if strings.HasPrefix(normalizedArg, "--http-header-fields=") && strings.Contains(normalizedArg, "referer:") { return true } if normalizedArg == "--http-header-fields" && i+1 < len(args) { nextArg := strings.ToLower(strings.TrimSpace(args[i+1])) if strings.Contains(nextArg, "referer:") { return true } } } return false } func StartVideo(link string, args []string, title string, anime *Anime) (string, error) { var command *exec.Cmd var mpvSocketPath string var err error userConfig := GetGlobalConfig() // Add custom MPV arguments from config if they exist if userConfig.MpvArgs != nil { args = append(args, userConfig.MpvArgs...) } shouldSetDefaultReferrer := isHTTPStreamLink(link) && !hasMPVReferrerArg(args) if shouldSetDefaultReferrer { referrer := defaultStreamReferrer if GetProvider().Name() == "animepahe" { referrer = "https://kwik.cx/" } args = append(args, fmt.Sprintf("--referrer=%s", referrer)) } // Check if we have an existing socket and if MPV is still running if anime.Ep.Player.SocketPath != "" && IsMPVRunning(anime.Ep.Player.SocketPath) { // Reuse existing socket mpvSocketPath = anime.Ep.Player.SocketPath if shouldSetDefaultReferrer { referrer := defaultStreamReferrer if GetProvider().Name() == "animepahe" { referrer = "https://kwik.cx/" } _, referrerErr := MPVSendCommand(mpvSocketPath, []interface{}{"set_property", "referrer", referrer}) if referrerErr != nil { Log(fmt.Sprintf("Failed to set referrer property: %v", referrerErr)) } } // Load the new file in the existing MPV instance command := []interface{}{"loadfile", link} _, err = MPVSendCommand(mpvSocketPath, command) if err != nil { return "", fmt.Errorf("failed to load file in existing MPV instance: %w", err) } // Wait a brief moment for the file to load time.Sleep(100 * time.Millisecond) // Update the window title titleCommand := []interface{}{"set_property", "force-media-title", title} _, err = MPVSendCommand(mpvSocketPath, titleCommand) if err != nil { Log(fmt.Sprintf("Failed to update title: %v", err)) } // Also update the window title property windowTitleCommand := []interface{}{"set_property", "title", title} _, err = MPVSendCommand(mpvSocketPath, windowTitleCommand) if err != nil { Log(fmt.Sprintf("Failed to update window title: %v", err)) } return mpvSocketPath, nil } if anime.Ep.Player.SocketPath == "" { // Generate a random number for the socket path randomBytes := make([]byte, 4) _, err = rand.Read(randomBytes) if err != nil { Log("Failed to generate random number") return "", fmt.Errorf("failed to generate random number: %w", err) } randomNumber := fmt.Sprintf("%x", randomBytes) // Create the mpv socket path with the random number if runtime.GOOS == "windows" { mpvSocketPath = fmt.Sprintf(`\\.\pipe\curd_mpvsocket_%s`, randomNumber) } else { mpvSocketPath = fmt.Sprintf("/tmp/curd_mpvsocket_%s", randomNumber) } } else { mpvSocketPath = anime.Ep.Player.SocketPath } // Add the title to MPV arguments titleArgs := []string{fmt.Sprintf("--title=%s", title), fmt.Sprintf("--force-media-title=%s", title)} // Keep the window open after episode completes, new episode starts in the same mpv window args = append(args, "--force-window=yes", "--idle=yes") args = append(args, titleArgs...) // Prepare arguments for mpv-compatible players. var mpvArgs []string mpvArgs = append(mpvArgs, "--no-terminal", "--really-quiet", fmt.Sprintf("--input-ipc-server=%s", mpvSocketPath)) // Add any additional arguments passed if len(args) > 0 { mpvArgs = append(mpvArgs, args...) } mpvArgs = append(mpvArgs, link) // Detect Android strictly from GOOS to avoid false positives from PATH binaries. isAndroid := runtime.GOOS == "android" if isAndroid { amBinary, resolveErr := resolveExecutable("/system/bin/am") if resolveErr != nil { amBinary, resolveErr = resolveExecutable("am") if resolveErr != nil { CurdOut("Error: Android activity manager binary not found") return "", fmt.Errorf("failed to locate android activity manager binary: %w", resolveErr) } } // Only use MPV on Android via intent cmdArgs := []string{ "start", "--user", "0", "-a", "android.intent.action.VIEW", "-d", link, "-n", "is.xyz.mpv/.MPVActivity", } command = exec.Command(amBinary, cmdArgs...) err = command.Start() if err != nil { CurdOut("Error: Failed to start android intent") return "", fmt.Errorf("failed to start android intent: %w", err) } return "android-intent", nil } resolvedPlayerBinary, effectivePlayerName, err := resolveConfiguredPlayerBinary(userConfig.Player) if err != nil { CurdOut("Error: Failed to resolve media player") Log(fmt.Sprintf("Player resolution failed for '%s': %v", userConfig.Player, err)) return "", err } playerArgs := mpvArgs if isIINAPlayer(effectivePlayerName, resolvedPlayerBinary) { playerArgs = translateMPVArgsForIINA(mpvArgs) playerArgs = append(playerArgs, "--no-stdin") } command = exec.Command(resolvedPlayerBinary, playerArgs...) // Start the selected mpv-compatible player process err = command.Start() if err != nil { CurdOut(fmt.Sprintf("Error: Failed to start %s process", effectivePlayerName)) return "", fmt.Errorf("failed to start %s: %w", effectivePlayerName, err) } // Wait for the socket to become available with retries socketReady := false maxRetries := 10 retryDelay := 300 * time.Millisecond Log(fmt.Sprintf("Waiting for MPV socket to be ready at %s", mpvSocketPath)) for i := 0; i < maxRetries; i++ { time.Sleep(retryDelay) // Try to connect to the socket conn, err := connectToPipe(mpvSocketPath) if err == nil { conn.Close() socketReady = true Log(fmt.Sprintf("MPV socket ready after %d attempts", i+1)) break } Log(fmt.Sprintf("Attempt %d/%d - Socket not ready yet: %v", i+1, maxRetries, err)) } if !socketReady { Log(fmt.Sprintf("Failed to connect to MPV socket after %d attempts", maxRetries)) // Don't fail here, just warn and continue - the next commands will handle any further issues } return mpvSocketPath, nil } // Helper function to join args with a space func joinArgs(args []string) string { result := "" for i, arg := range args { if i > 0 { result += " " } result += arg } return result } func MPVSendCommand(ipcSocketPath string, command []interface{}) (interface{}, error) { // Use a retry mechanism for transient errors var lastErr error maxRetries := 3 retryDelay := 100 * time.Millisecond for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { time.Sleep(retryDelay) Log(fmt.Sprintf("Retrying MPV command, attempt %d/%d", attempt+1, maxRetries)) } conn, err := connectToPipe(ipcSocketPath) if err != nil { lastErr = err Log(fmt.Sprintf("Connect error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue // Try again } defer conn.Close() commandStr, err := json.Marshal(map[string]interface{}{ "command": command, }) if err != nil { return nil, err // Don't retry on JSON marshalling errors } // Send the command _, err = conn.Write(append(commandStr, '\n')) if err != nil { lastErr = err Log(fmt.Sprintf("Write error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue // Try again } // Receive the response with timeout buf := make([]byte, 4096) // Set read deadline for 1 second if deadline, ok := conn.(interface{ SetReadDeadline(time.Time) error }); ok { deadline.SetReadDeadline(time.Now().Add(1 * time.Second)) } n, err := conn.Read(buf) if err != nil { lastErr = err Log(fmt.Sprintf("Read error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue // Try again } var response map[string]interface{} if err := json.Unmarshal(buf[:n], &response); err != nil { lastErr = err Log(fmt.Sprintf("JSON parse error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue // Try again } // Success! if data, exists := response["data"]; exists { return data, nil } return nil, nil } // All retries failed return nil, fmt.Errorf("command failed after %d attempts: %w", maxRetries, lastErr) } func SeekMPV(ipcSocketPath string, time int) (interface{}, error) { command := []interface{}{"seek", time, "absolute"} return MPVSendCommand(ipcSocketPath, command) } func GetMPVPausedStatus(ipcSocketPath string) (bool, error) { status, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "pause"}) if err != nil || status == nil { return false, err } paused, ok := status.(bool) if ok { return paused, nil } return false, nil } func GetMPVPlaybackSpeed(ipcSocketPath string) (float64, error) { speed, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "speed"}) if err != nil || speed == nil { Log("Failed to get playback speed.") return 0, err } currentSpeed, ok := speed.(float64) if ok { return currentSpeed, nil } return 0, nil } func GetPercentageWatched(ipcSocketPath string) (float64, error) { currentTime, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "time-pos"}) if err != nil || currentTime == nil { return 0, err } duration, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "duration"}) if err != nil || duration == nil { return 0, err } currTime, ok1 := currentTime.(float64) dur, ok2 := duration.(float64) if ok1 && ok2 && dur > 0 { percentageWatched := (currTime / dur) * 100 return percentageWatched, nil } return 0, nil } func PercentageWatched(playbackTime int, duration int) float64 { if duration > 0 { percentage := (float64(playbackTime) / float64(duration)) * 100 return percentage } return float64(0) } func HasActivePlayback(ipcSocketPath string) (bool, error) { maxRetries := 3 var lastErr error for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { time.Sleep(200 * time.Millisecond) } // Get the time-pos property from MPV timePos, err := MPVSendCommand(ipcSocketPath, []interface{}{"get_property", "time-pos"}) if err != nil { // Check specifically for "property unavailable" error - this is a valid state if strings.Contains(err.Error(), "property unavailable") { Log("HasActivePlayback: Property unavailable, nothing is playing") return false, nil } // Check for socket connection errors - these might be temporary if strings.Contains(err.Error(), "connect: connection refused") || strings.Contains(err.Error(), "connect: no such file or directory") { lastErr = err Log(fmt.Sprintf("HasActivePlayback: Connection error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue // Try again } // Other errors should be returned return false, fmt.Errorf("error getting time-pos: %w", err) } // If we got a valid response, something is playing if timePos != nil { return true, nil } // No error but no position either - likely nothing is playing return false, nil } // If we get here, all retries failed Log(fmt.Sprintf("HasActivePlayback: Failed after %d attempts: %v", maxRetries, lastErr)) return false, fmt.Errorf("failed to check playback status: %w", lastErr) } func IsMPVRunning(socketPath string) bool { if socketPath == "" { return false } maxRetries := 3 for attempt := 0; attempt < maxRetries; attempt++ { if attempt > 0 { time.Sleep(200 * time.Millisecond) Log(fmt.Sprintf("Retrying MPV connection check, attempt %d/%d", attempt+1, maxRetries)) } // Try to connect to the socket conn, err := connectToPipe(socketPath) if err != nil { Log(fmt.Sprintf("IsMPVRunning: Connection error (attempt %d/%d): %v", attempt+1, maxRetries, err)) continue } defer conn.Close() // Send a simple command to check if MPV responds _, err = MPVSendCommand(socketPath, []interface{}{"get_property", "pid"}) if err == nil { return true } Log(fmt.Sprintf("IsMPVRunning: Command failed (attempt %d/%d): %v", attempt+1, maxRetries, err)) } // After all retries, conclude MPV is not running return false } func ExitMPV(ipcSocketPath string) error { // Send command to close MPV _, err := MPVSendCommand(ipcSocketPath, []interface{}{"quit"}) if err != nil { Log("Error closing MPV: " + err.Error()) } return err } // MPVEventListener represents a structure to track MPV events type MPVEventListener struct { SocketPath string LastPosition float64 LastPauseState bool SeekDetected bool PlayPauseDetected bool IsListening bool mu sync.Mutex // Add mutex for thread safety } // SetupMPVEventListening configures MPV to send property change notifications func SetupMPVEventListening(ipcSocketPath string) error { Log("=== SETTING UP MPV EVENT LISTENING ===") Log("Socket path: " + ipcSocketPath) // Observe time-pos property for seek detection Log("Setting up time-pos observer...") response1, err := MPVSendCommand(ipcSocketPath, []interface{}{"observe_property", 1, "time-pos"}) if err != nil { Log("FAILED: Error setting up time-pos observer: " + err.Error()) return err } Log(fmt.Sprintf("SUCCESS: time-pos observer setup. Response: %v", response1)) // Observe pause property for play/pause detection Log("Setting up pause observer...") response2, err := MPVSendCommand(ipcSocketPath, []interface{}{"observe_property", 2, "pause"}) if err != nil { Log("FAILED: Error setting up pause observer: " + err.Error()) return err } Log(fmt.Sprintf("SUCCESS: pause observer setup. Response: %v", response2)) // Observe seeking property for direct seek detection Log("Setting up seeking observer...") response3, err := MPVSendCommand(ipcSocketPath, []interface{}{"observe_property", 3, "seeking"}) if err != nil { Log("FAILED: Error setting up seeking observer: " + err.Error()) return err } Log(fmt.Sprintf("SUCCESS: seeking observer setup. Response: %v", response3)) Log("=== MPV EVENT LISTENING SETUP COMPLETED ===") return nil } // StartMPVEventListener starts a dedicated goroutine to listen for MPV events func StartMPVEventListener(ipcSocketPath string, eventCallback func(string, interface{})) error { go func() { Log("Starting MPV event listener goroutine for socket: " + ipcSocketPath) conn, err := connectToPipe(ipcSocketPath) if err != nil { Log("Failed to connect to MPV socket for event listening: " + err.Error()) return } defer conn.Close() Log("Successfully connected to MPV socket for event listening") buf := make([]byte, 4096) eventCount := 0 for { // Set read timeout to avoid hanging indefinitely if deadline, ok := conn.(interface{ SetReadDeadline(time.Time) error }); ok { deadline.SetReadDeadline(time.Now().Add(10 * time.Second)) } Log("Waiting for MPV events...") n, err := conn.Read(buf) if err != nil { Log("MPV event listener read error: " + err.Error()) if strings.Contains(err.Error(), "timeout") { Log("Read timeout - continuing to wait for events...") continue } break } if n > 0 { eventCount++ rawMessage := string(buf[:n]) Log(fmt.Sprintf("Raw MPV message #%d (%d bytes): %s", eventCount, n, rawMessage)) var response map[string]interface{} if err := json.Unmarshal(buf[:n], &response); err != nil { Log("MPV event listener JSON parse error: " + err.Error()) Log("Raw data that failed to parse: " + rawMessage) continue } Log(fmt.Sprintf("Parsed MPV response: %+v", response)) // Handle both events and property changes if event, exists := response["event"]; exists { eventType := event.(string) Log(fmt.Sprintf("Event type detected: %s", eventType)) // Handle specific MPV events switch eventType { case "playback-restart": Log("PLAYBACK-RESTART EVENT DETECTED (SEEK)") if eventCallback != nil { eventCallback("playback-restart", true) } case "pause": Log("PAUSE EVENT DETECTED") if eventCallback != nil { eventCallback("pause-event", true) } case "unpause": Log("UNPAUSE EVENT DETECTED") if eventCallback != nil { eventCallback("unpause-event", false) } case "property-change": if name, exists := response["name"]; exists { if data, exists := response["data"]; exists { propertyName := name.(string) Log(fmt.Sprintf("MPV PROPERTY CHANGE EVENT - %s: %v", propertyName, data)) // Call the callback with the event details if eventCallback != nil { Log(fmt.Sprintf("Calling event callback for property: %s", propertyName)) eventCallback(propertyName, data) } else { Log("WARNING: No event callback set!") } } else { Log("Property change event missing 'data' field") } } else { Log("Property change event missing 'name' field") } default: Log(fmt.Sprintf("📋 Other MPV event: %s (full data: %+v)", eventType, response)) // Also forward unknown events to callback in case we need to handle more if eventCallback != nil { eventCallback(eventType, response) } } } else { Log("Non-event message received (probably command response)") } } } Log(fmt.Sprintf("=== MPV EVENT LISTENER EXITING (processed %d events) ===", eventCount)) }() return nil } // MPVSeekDetector provides enhanced seek detection using actual MPV events func CreateMPVSeekDetector(ipcSocketPath string) *MPVEventListener { detector := &MPVEventListener{ SocketPath: ipcSocketPath, LastPosition: -1, LastPauseState: false, SeekDetected: false, PlayPauseDetected: false, IsListening: false, } Log("Created MPV seek detector for socket: " + ipcSocketPath) return detector } // ProcessMPVEvent processes incoming MPV events and detects seeks and play/pause changes func (detector *MPVEventListener) ProcessMPVEvent(propertyName string, data interface{}) { Log(fmt.Sprintf("=== PROCESSING MPV EVENT: %s ===", propertyName)) Log(fmt.Sprintf("Event data: %v (type: %T)", data, data)) detector.mu.Lock() defer detector.mu.Unlock() switch propertyName { case "playback-restart": Log("Processing playback-restart event (SEEK DETECTED)...") detector.SeekDetected = true Log(fmt.Sprintf("SEEK EVENT DETECTED VIA PLAYBACK-RESTART FLAG SET TO TRUE at %s", time.Now().Format("15:04:05.000"))) case "pause-event": Log("Processing pause event...") detector.PlayPauseDetected = true detector.LastPauseState = true Log(" PAUSE EVENT DETECTED ") case "unpause-event": Log(" Processing unpause event...") detector.PlayPauseDetected = true detector.LastPauseState = false Log("UNPAUSE EVENT DETECTED") case "time-pos": Log("Processing time-pos event...") if data != nil { if position, ok := data.(float64); ok { Log(fmt.Sprintf("POSITION UPDATE: %f seconds (was: %f)", position, detector.LastPosition)) if detector.LastPosition >= 0 { // Check for significant position jump (potential seek) - backup method positionDiff := position - detector.LastPosition Log(fmt.Sprintf("Position difference: %f seconds", positionDiff)) if positionDiff < -2 || positionDiff > 5 { // Backwards seek or large forward jump detector.SeekDetected = true Log(fmt.Sprintf("BACKUP SEEK DETECTED Position jumped from %f to %f (diff: %f)", detector.LastPosition, position, positionDiff)) } else { Log("Normal position progression - no seek detected") } } else { Log("First position update - no seek detection yet") } detector.LastPosition = position } else { Log(fmt.Sprintf("WARNING: time-pos data is not float64: %T", data)) } } else { Log("WARNING: time-pos data is nil") } case "pause": Log("Processing pause property change...") if data != nil { if pauseState, ok := data.(bool); ok { Log(fmt.Sprintf(" PAUSE STATE UPDATE: %t (was: %t)", pauseState, detector.LastPauseState)) if detector.LastPauseState != pauseState { detector.PlayPauseDetected = true Log(fmt.Sprintf("PLAY/PAUSE PROPERTY CHANGE DETECTED Changed from %t to %t", detector.LastPauseState, pauseState)) } else { Log("Pause state unchanged - no play/pause event") } detector.LastPauseState = pauseState } else { Log(fmt.Sprintf("WARNING: pause data is not boolean: %T", data)) } } else { Log("WARNING: pause data is nil") } case "seeking": Log("Processing seeking property change...") if data != nil { if seeking, ok := data.(bool); ok { Log(fmt.Sprintf("Seeking state: %t", seeking)) if seeking { detector.SeekDetected = true Log("SEEKING PROPERTY CHANGE DETECTED MPV reported seeking=true") } else { Log("Seeking ended (seeking=false)") } } else { Log(fmt.Sprintf("WARNING: seeking data is not boolean: %T", data)) } } else { Log("WARNING: seeking data is nil") } default: Log(fmt.Sprintf("Unknown event: %s", propertyName)) } Log("=== EVENT PROCESSING COMPLETE ===") } // HasSeekOccurred checks and resets the seek detection flag func (detector *MPVEventListener) HasSeekOccurred() bool { detector.mu.Lock() defer detector.mu.Unlock() // Log(fmt.Sprintf("HasSeekOccurred called at %s - SeekDetected flag: %t", time.Now().Format("15:04:05.000"), detector.SeekDetected)) if detector.SeekDetected { detector.SeekDetected = false Log(fmt.Sprintf("Seek event consumed and reset at %s - RETURNING TRUE", time.Now().Format("15:04:05.000"))) return true } // Log(fmt.Sprintf("No seek event at %s - RETURNING FALSE", time.Now().Format("15:04:05.000"))) return false } // HasPlayPauseChanged checks and resets the play/pause detection flag func (detector *MPVEventListener) HasPlayPauseChanged() bool { detector.mu.Lock() defer detector.mu.Unlock() // Log(fmt.Sprintf("HasPlayPauseChanged called - PlayPauseDetected flag: %t", detector.PlayPauseDetected)) if detector.PlayPauseDetected { detector.PlayPauseDetected = false Log("Play/pause event consumed and reset - RETURNING TRUE") return true } return false } ================================================ FILE: internal/provider.go ================================================ package internal // Provider interface defines methods for an anime provider. type Provider interface { Name() string SearchAnime(query, mode string) ([]SelectionOption, error) EpisodesList(showID, mode string) ([]string, error) GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) } // Global variable to keep the current provider var CurrentProvider Provider func GetProvider() Provider { if CurrentProvider != nil { return CurrentProvider } config := GetGlobalConfig() if config != nil && config.Provider == "animepahe" { CurrentProvider = &AnimepaheProvider{} } else { CurrentProvider = &AllanimeProvider{} } return CurrentProvider } // Wrap functions so they can be easily called func SearchAnime(query, mode string) ([]SelectionOption, error) { return GetProvider().SearchAnime(query, mode) } func EpisodesList(showID, mode string) ([]string, error) { return GetProvider().EpisodesList(showID, mode) } func GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) { return GetProvider().GetEpisodeURL(config, id, epNo) } ================================================ FILE: internal/provider_allanime.go ================================================ package internal type AllanimeProvider struct{} func (p *AllanimeProvider) Name() string { return "allanime" } func (p *AllanimeProvider) SearchAnime(query, mode string) ([]SelectionOption, error) { return searchAllAnime(query, mode) } func (p *AllanimeProvider) EpisodesList(showID, mode string) ([]string, error) { return getAllAnimeEpisodesList(showID, mode) } func (p *AllanimeProvider) GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) { return getAllanimeEpisodeURL(config, id, epNo) } ================================================ FILE: internal/provider_animepahe.go ================================================ package internal import ( "encoding/json" "fmt" "io" "net/http" "net/url" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "github.com/go-rod/rod" "github.com/go-rod/rod/lib/launcher" ) var animepaheCookiesBypassed bool func getCookieFilePath() string { return filepath.Join(GetStoragePath(), "animepahe_cookies.json") } func saveCookies(cookies []*http.Cookie) { cookieFilePath := getCookieFilePath() os.MkdirAll(filepath.Dir(cookieFilePath), 0755) b, _ := json.Marshal(cookies) os.WriteFile(cookieFilePath, b, 0644) } func loadCookies() []*http.Cookie { cookieFilePath := getCookieFilePath() b, err := os.ReadFile(cookieFilePath) if err != nil { return nil } var cookies []*http.Cookie json.Unmarshal(b, &cookies) return cookies } func checkCookiesValid() bool { req, _ := http.NewRequest("GET", "https://animepahe.pw/api?m=search&q=test", nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Referer", "https://animepahe.pw/") req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") req.Header.Set("X-Requested-With", "XMLHttpRequest") resp, err := sharedHTTPClient.Do(req) if err != nil { return false } defer resp.Body.Close() if resp.StatusCode != 200 { return false } contentType := resp.Header.Get("Content-Type") return strings.Contains(contentType, "application/json") } func (p *AnimepaheProvider) ensureBypass() error { if animepaheCookiesBypassed { return nil } cookies := loadCookies() if len(cookies) > 0 { u, _ := url.Parse("https://animepahe.pw") SetCookiesForAnimepahe(u, cookies) if checkCookiesValid() { animepaheCookiesBypassed = true Log("Successfully restored Animepahe session from cache.") return nil } Log("Cached Animepahe session expired. Requesting new session...") } CurdOut("Solving Animepahe DDoS-Guard challenge via headless browser...") l := launcher.New().Headless(true) defer l.Cleanup() browser := rod.New().ControlURL(l.MustLaunch()).MustConnect() defer browser.MustClose() page := browser.MustPage("https://animepahe.pw/") page.MustWaitLoad() for i := 0; i < 30; i++ { info, err := page.Info() if err == nil && info.Title != "DDoS-Guard" && info.Title != "Just a moment..." && info.Title != "" { break } time.Sleep(500 * time.Millisecond) } rodCookies, err := page.Cookies(nil) if err != nil { return fmt.Errorf("failed to acquire DDoS guard cookies: %v", err) } u, _ := url.Parse("https://animepahe.pw") var httpCookies []*http.Cookie for _, cookie := range rodCookies { httpCookies = append(httpCookies, &http.Cookie{ Name: cookie.Name, Value: cookie.Value, }) } SetCookiesForAnimepahe(u, httpCookies) if !checkCookiesValid() { return fmt.Errorf("bypassed cookies are still invalid") } saveCookies(httpCookies) animepaheCookiesBypassed = true CurdOut("Successfully bypassed DDoS-Guard.") return nil } type AnimepaheProvider struct{} func (p *AnimepaheProvider) Name() string { return "animepahe" } type AnimepaheSearchItem struct { ID int `json:"id"` Title string `json:"title"` Type string `json:"type"` Episodes int `json:"episodes"` Status string `json:"status"` Season string `json:"season"` Year int `json:"year"` Score float64 `json:"score"` Poster string `json:"poster"` Session string `json:"session"` } type animepaheSearchResponse struct { Total int `json:"total"` PerPage int `json:"per_page"` CurrentPage int `json:"current_page"` LastPage int `json:"last_page"` Data []AnimepaheSearchItem `json:"data"` } func (p *AnimepaheProvider) SearchAnime(query, mode string) ([]SelectionOption, error) { if err := p.ensureBypass(); err != nil { return nil, err } // animepahe doesn't distinguish sub/dub at the search level searchUrl := fmt.Sprintf("https://animepahe.pw/api?m=search&q=%s", url.QueryEscape(query)) req, _ := http.NewRequest("GET", searchUrl, nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Referer", "https://animepahe.pw/") req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") req.Header.Set("X-Requested-With", "XMLHttpRequest") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } var searchResp animepaheSearchResponse err = json.Unmarshal(body, &searchResp) if err != nil { return nil, err } var result []SelectionOption for _, item := range searchResp.Data { label := fmt.Sprintf("%s (%d episodes) [animepahe]", item.Title, item.Episodes) result = append(result, SelectionOption{ Title: item.Title, Label: label, Key: item.Session, // Using session as ID Thumbnail: item.Poster, ExtraData: item, }) } return result, nil } type animepaheEpisodesResponse struct { Total int `json:"total"` PerPage int `json:"per_page"` CurrentPage int `json:"current_page"` LastPage int `json:"last_page"` Data []struct { ID int `json:"id"` AnimeID int `json:"anime_id"` Episode int `json:"episode"` Session string `json:"session"` } `json:"data"` } func (p *AnimepaheProvider) EpisodesList(showID, mode string) ([]string, error) { if err := p.ensureBypass(); err != nil { return nil, err } // showID here is the session ID for animepahe var allEpisodes []int page := 1 for { epUrl := fmt.Sprintf("https://animepahe.pw/api?m=release&id=%s&sort=episode_asc&page=%d", showID, page) req, _ := http.NewRequest("GET", epUrl, nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Referer", "https://animepahe.pw/") req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") req.Header.Set("X-Requested-With", "XMLHttpRequest") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, err } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, err } var epsResp animepaheEpisodesResponse err = json.Unmarshal(body, &epsResp) if err != nil { return nil, err } for _, ep := range epsResp.Data { allEpisodes = append(allEpisodes, ep.Episode) } if epsResp.CurrentPage >= epsResp.LastPage { break } page++ } sort.Ints(allEpisodes) var result []string for _, ep := range allEpisodes { result = append(result, strconv.Itoa(ep)) } return result, nil } func (p *AnimepaheProvider) GetEpisodeURL(config CurdConfig, id string, epNo int) ([]string, error) { if err := p.ensureBypass(); err != nil { return nil, err } // 1. Get episode session ID // Map the 1-based Anilist episode number to Animepahe's actual episode number eps, err := p.EpisodesList(id, "") if err != nil { return nil, err } if epNo < 1 || epNo > len(eps) { return nil, fmt.Errorf("episode %d out of bounds (found %d episodes)", epNo, len(eps)) } mappedEpNo, err := strconv.Atoi(eps[epNo-1]) if err != nil { return nil, fmt.Errorf("invalid episode number format in provider: %v", err) } // Let's do a simple scan for the episode session: var episodeSession string page := 1 for { reqUrl := fmt.Sprintf("https://animepahe.pw/api?m=release&id=%s&sort=episode_asc&page=%d", id, page) req, _ := http.NewRequest("GET", reqUrl, nil) req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") req.Header.Set("Referer", "https://animepahe.pw/") req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01") req.Header.Set("X-Requested-With", "XMLHttpRequest") resp, err := sharedHTTPClient.Do(req) if err != nil { return nil, err } body, err := io.ReadAll(resp.Body) resp.Body.Close() if err != nil { return nil, err } var epsResp animepaheEpisodesResponse err = json.Unmarshal(body, &epsResp) if err != nil { return nil, err } for _, item := range epsResp.Data { if item.Episode == mappedEpNo { episodeSession = item.Session break } } if episodeSession != "" || epsResp.CurrentPage >= epsResp.LastPage { break } page++ } if episodeSession == "" { return nil, fmt.Errorf("episode %d (mapped to %d) not found", epNo, mappedEpNo) } // player page: https://animepahe.pw/play// // stream links in player page: