revert: remove torrent streaming feature
This commit is contained in:
@@ -25,8 +25,6 @@ type Anime struct {
|
||||
TitleEnglish sql.NullString `json:"title_english"`
|
||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||
Airing sql.NullBool `json:"airing"`
|
||||
MagnetLink sql.NullString `json:"magnet_link"`
|
||||
TorrentHash sql.NullString `json:"torrent_hash"`
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
|
||||
@@ -24,16 +24,14 @@ DELETE FROM session WHERE id = ?;
|
||||
DELETE FROM session WHERE user_id = ?;
|
||||
|
||||
-- name: UpsertAnime :one
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, magnet_link, torrent_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title_original = excluded.title_original,
|
||||
title_english = excluded.title_english,
|
||||
title_japanese = excluded.title_japanese,
|
||||
image_url = excluded.image_url,
|
||||
airing = excluded.airing,
|
||||
magnet_link = excluded.magnet_link,
|
||||
torrent_hash = excluded.torrent_hash
|
||||
airing = excluded.airing
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAnime :one
|
||||
@@ -58,9 +56,7 @@ SELECT
|
||||
a.title_english,
|
||||
a.title_japanese,
|
||||
a.image_url,
|
||||
a.airing,
|
||||
a.magnet_link,
|
||||
a.torrent_hash
|
||||
a.airing
|
||||
FROM watch_list_entry e
|
||||
JOIN anime a ON e.anime_id = a.id
|
||||
WHERE e.user_id = ?
|
||||
|
||||
@@ -93,7 +93,7 @@ func (q *Queries) DeleteWatchListEntry(ctx context.Context, arg DeleteWatchListE
|
||||
}
|
||||
|
||||
const getAnime = `-- name: GetAnime :one
|
||||
SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing, magnet_link, torrent_hash FROM anime WHERE id = ? LIMIT 1
|
||||
SELECT id, title_original, image_url, created_at, title_english, title_japanese, airing FROM anime WHERE id = ? LIMIT 1
|
||||
`
|
||||
|
||||
func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) {
|
||||
@@ -107,8 +107,6 @@ func (q *Queries) GetAnime(ctx context.Context, id int64) (Anime, error) {
|
||||
&i.TitleEnglish,
|
||||
&i.TitleJapanese,
|
||||
&i.Airing,
|
||||
&i.MagnetLink,
|
||||
&i.TorrentHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -168,9 +166,7 @@ SELECT
|
||||
a.title_english,
|
||||
a.title_japanese,
|
||||
a.image_url,
|
||||
a.airing,
|
||||
a.magnet_link,
|
||||
a.torrent_hash
|
||||
a.airing
|
||||
FROM watch_list_entry e
|
||||
JOIN anime a ON e.anime_id = a.id
|
||||
WHERE e.user_id = ?
|
||||
@@ -189,8 +185,6 @@ type GetUserWatchListRow struct {
|
||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||
ImageUrl string `json:"image_url"`
|
||||
Airing sql.NullBool `json:"airing"`
|
||||
MagnetLink sql.NullString `json:"magnet_link"`
|
||||
TorrentHash sql.NullString `json:"torrent_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
|
||||
@@ -214,8 +208,6 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
|
||||
&i.TitleJapanese,
|
||||
&i.ImageUrl,
|
||||
&i.Airing,
|
||||
&i.MagnetLink,
|
||||
&i.TorrentHash,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -255,17 +247,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
|
||||
}
|
||||
|
||||
const upsertAnime = `-- name: UpsertAnime :one
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing, magnet_link, torrent_hash)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO anime (id, title_original, title_english, title_japanese, image_url, airing)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
title_original = excluded.title_original,
|
||||
title_english = excluded.title_english,
|
||||
title_japanese = excluded.title_japanese,
|
||||
image_url = excluded.image_url,
|
||||
airing = excluded.airing,
|
||||
magnet_link = excluded.magnet_link,
|
||||
torrent_hash = excluded.torrent_hash
|
||||
RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing, magnet_link, torrent_hash
|
||||
airing = excluded.airing
|
||||
RETURNING id, title_original, image_url, created_at, title_english, title_japanese, airing
|
||||
`
|
||||
|
||||
type UpsertAnimeParams struct {
|
||||
@@ -275,8 +265,6 @@ type UpsertAnimeParams struct {
|
||||
TitleJapanese sql.NullString `json:"title_japanese"`
|
||||
ImageUrl string `json:"image_url"`
|
||||
Airing sql.NullBool `json:"airing"`
|
||||
MagnetLink sql.NullString `json:"magnet_link"`
|
||||
TorrentHash sql.NullString `json:"torrent_hash"`
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime, error) {
|
||||
@@ -287,8 +275,6 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
|
||||
arg.TitleJapanese,
|
||||
arg.ImageUrl,
|
||||
arg.Airing,
|
||||
arg.MagnetLink,
|
||||
arg.TorrentHash,
|
||||
)
|
||||
var i Anime
|
||||
err := row.Scan(
|
||||
@@ -299,8 +285,6 @@ func (q *Queries) UpsertAnime(ctx context.Context, arg UpsertAnimeParams) (Anime
|
||||
&i.TitleEnglish,
|
||||
&i.TitleJapanese,
|
||||
&i.Airing,
|
||||
&i.MagnetLink,
|
||||
&i.TorrentHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -109,7 +109,15 @@ func (h *Handler) HandleAnimeDetails(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIAnimeRelations(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
path := r.URL.Path[len("/api/anime/"):]
|
||||
idStr := ""
|
||||
for i, c := range path {
|
||||
if c == '/' {
|
||||
idStr = path[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
@@ -164,29 +172,3 @@ func (h *Handler) HandleQuickSearch(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(output)
|
||||
}
|
||||
|
||||
func (h *Handler) HandleAPIAnimeEpisodes(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := r.PathValue("id")
|
||||
id, _ := strconv.Atoi(idStr)
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.svc.GetAnime(id)
|
||||
if err != nil {
|
||||
log.Printf("anime fetch error: %v", err)
|
||||
http.Error(w, "Failed to fetch anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
episodes, err := h.svc.GetEpisodes(id, 1)
|
||||
if err != nil {
|
||||
log.Printf("episodes fetch error: %v", err)
|
||||
// Return empty episodes instead of error
|
||||
templates.EpisodesList(id, anime.Title, nil, anime.Episodes).Render(r.Context(), w)
|
||||
return
|
||||
}
|
||||
|
||||
templates.EpisodesList(id, anime.Title, episodes.Episodes, anime.Episodes).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
@@ -51,15 +51,3 @@ func (s *Service) GetAnimeDetails(ctx context.Context, id int, userID string) (j
|
||||
func (s *Service) GetRelations(id int) []jikan.RelationEntry {
|
||||
return s.jikanClient.GetFullRelations(id)
|
||||
}
|
||||
|
||||
func (s *Service) GetEpisodes(id int, page int) (jikan.EpisodesResult, error) {
|
||||
return s.jikanClient.GetEpisodes(id, page)
|
||||
}
|
||||
|
||||
func (s *Service) GetAllEpisodes(id int) ([]jikan.Episode, error) {
|
||||
return s.jikanClient.GetAllEpisodes(id)
|
||||
}
|
||||
|
||||
func (s *Service) GetAnime(id int) (jikan.Anime, error) {
|
||||
return s.jikanClient.GetAnimeByID(id)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ type Client struct {
|
||||
topCache *expirable.LRU[int, TopAnimeResult]
|
||||
animeCache *expirable.LRU[int, Anime]
|
||||
relationsCache *expirable.LRU[int, JikanRelationsResponse]
|
||||
episodesCache *expirable.LRU[string, EpisodesResult]
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
@@ -24,7 +23,6 @@ func NewClient() *Client {
|
||||
topCache := expirable.NewLRU[int, TopAnimeResult](100, nil, time.Hour*1)
|
||||
animeCache := expirable.NewLRU[int, Anime](1000, nil, time.Hour*24)
|
||||
relationsCache := expirable.NewLRU[int, JikanRelationsResponse](1000, nil, time.Hour*24)
|
||||
episodesCache := expirable.NewLRU[string, EpisodesResult](500, nil, time.Hour*6)
|
||||
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
@@ -33,7 +31,6 @@ func NewClient() *Client {
|
||||
topCache: topCache,
|
||||
animeCache: animeCache,
|
||||
relationsCache: relationsCache,
|
||||
episodesCache: episodesCache,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,44 +63,3 @@ func (c *Client) fetchWithRetry(urlStr string, out interface{}) error {
|
||||
}
|
||||
return fmt.Errorf("max retries exceeded for %s", urlStr)
|
||||
}
|
||||
|
||||
// GetEpisodes fetches episodes for an anime (paginated, 100 per page)
|
||||
func (c *Client) GetEpisodes(animeID int, page int) (EpisodesResult, error) {
|
||||
cacheKey := fmt.Sprintf("%d-%d", animeID, page)
|
||||
if cached, ok := c.episodesCache.Get(cacheKey); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
|
||||
var resp EpisodesResponse
|
||||
if err := c.fetchWithRetry(url, &resp); err != nil {
|
||||
return EpisodesResult{}, err
|
||||
}
|
||||
|
||||
result := EpisodesResult{
|
||||
Episodes: resp.Data,
|
||||
HasNextPage: resp.Pagination.HasNextPage,
|
||||
}
|
||||
c.episodesCache.Add(cacheKey, result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetAllEpisodes fetches all episodes for an anime (handles pagination)
|
||||
func (c *Client) GetAllEpisodes(animeID int) ([]Episode, error) {
|
||||
var allEpisodes []Episode
|
||||
page := 1
|
||||
|
||||
for {
|
||||
result, err := c.GetEpisodes(animeID, page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
allEpisodes = append(allEpisodes, result.Episodes...)
|
||||
if !result.HasNextPage {
|
||||
break
|
||||
}
|
||||
page++
|
||||
}
|
||||
|
||||
return allEpisodes, nil
|
||||
}
|
||||
|
||||
@@ -169,24 +169,3 @@ func (a Anime) DisplayTitle() string {
|
||||
}
|
||||
return a.Title
|
||||
}
|
||||
|
||||
// Episode represents a single anime episode from Jikan API
|
||||
type Episode struct {
|
||||
MalID int `json:"mal_id"`
|
||||
Title string `json:"title"`
|
||||
TitleJP string `json:"title_japanese"`
|
||||
TitleRom string `json:"title_romanji"`
|
||||
Aired string `json:"aired"`
|
||||
Filler bool `json:"filler"`
|
||||
Recap bool `json:"recap"`
|
||||
}
|
||||
|
||||
type EpisodesResponse struct {
|
||||
Data []Episode `json:"data"`
|
||||
Pagination Pagination `json:"pagination"`
|
||||
}
|
||||
|
||||
type EpisodesResult struct {
|
||||
Episodes []Episode
|
||||
HasNextPage bool
|
||||
}
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
package nyaa
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/golang-lru/v2/expirable"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
baseURL string
|
||||
cache *expirable.LRU[string, []Torrent]
|
||||
magnetCache *expirable.LRU[string, string]
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
Title string `json:"title"`
|
||||
Magnet string `json:"magnet"`
|
||||
Size string `json:"size"`
|
||||
Seeders int `json:"seeders"`
|
||||
Leechers int `json:"leechers"`
|
||||
Date string `json:"date"`
|
||||
Episode int `json:"episode"`
|
||||
ViewURL string `json:"view_url"`
|
||||
}
|
||||
|
||||
// RSS feed structures
|
||||
type rssResponse struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Channel rssChannel `xml:"channel"`
|
||||
}
|
||||
|
||||
type rssChannel struct {
|
||||
Items []rssItem `xml:"item"`
|
||||
}
|
||||
|
||||
type rssItem struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
GUID string `xml:"guid"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Seeders string `xml:"seeders"`
|
||||
Leechers string `xml:"leechers"`
|
||||
Size string `xml:"size"`
|
||||
}
|
||||
|
||||
func NewClient() *Client {
|
||||
cache := expirable.NewLRU[string, []Torrent](200, nil, time.Minute*15)
|
||||
magnetCache := expirable.NewLRU[string, string](500, nil, time.Hour*24)
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: 15 * time.Second},
|
||||
baseURL: "https://nyaa.si",
|
||||
cache: cache,
|
||||
magnetCache: magnetCache,
|
||||
}
|
||||
}
|
||||
|
||||
// SearchAnime searches for anime torrents on nyaa.si
|
||||
// category 1_2 = Anime - English-translated
|
||||
func (c *Client) SearchAnime(query string) ([]Torrent, error) {
|
||||
if cached, ok := c.cache.Get(query); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
// Build search URL with RSS feed
|
||||
params := url.Values{}
|
||||
params.Set("f", "0") // filter: no filter
|
||||
params.Set("c", "1_2") // category: Anime - English-translated
|
||||
params.Set("q", query)
|
||||
params.Set("s", "seeders") // sort by seeders
|
||||
params.Set("o", "desc") // descending order
|
||||
params.Set("page", "rss") // RSS format
|
||||
|
||||
reqURL := fmt.Sprintf("%s/?%s", c.baseURL, params.Encode())
|
||||
|
||||
resp, err := c.httpClient.Get(reqURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("nyaa request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("nyaa returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var rss rssResponse
|
||||
if err := xml.NewDecoder(resp.Body).Decode(&rss); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse nyaa rss: %w", err)
|
||||
}
|
||||
|
||||
torrents := make([]Torrent, 0, len(rss.Channel.Items))
|
||||
for _, item := range rss.Channel.Items {
|
||||
seeders, _ := strconv.Atoi(item.Seeders)
|
||||
leechers, _ := strconv.Atoi(item.Leechers)
|
||||
|
||||
// Skip dead torrents (0 seeders)
|
||||
if seeders == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract torrent ID from download link
|
||||
// Link format: https://nyaa.si/download/1234567.torrent
|
||||
viewURL := extractViewURL(item.Link)
|
||||
|
||||
t := Torrent{
|
||||
Title: item.Title,
|
||||
Magnet: "", // Will be fetched on demand
|
||||
Size: item.Size,
|
||||
Seeders: seeders,
|
||||
Leechers: leechers,
|
||||
Date: item.PubDate,
|
||||
Episode: parseEpisodeNumber(item.Title),
|
||||
ViewURL: viewURL,
|
||||
}
|
||||
|
||||
// Check if GUID is already a magnet link
|
||||
if strings.HasPrefix(item.GUID, "magnet:") {
|
||||
t.Magnet = item.GUID
|
||||
}
|
||||
|
||||
torrents = append(torrents, t)
|
||||
}
|
||||
|
||||
// Fetch magnets for top results (limit to avoid rate limiting)
|
||||
for i := range torrents {
|
||||
if i >= 20 {
|
||||
break
|
||||
}
|
||||
if torrents[i].Magnet == "" && torrents[i].ViewURL != "" {
|
||||
magnet, err := c.fetchMagnet(torrents[i].ViewURL)
|
||||
if err == nil {
|
||||
torrents[i].Magnet = magnet
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.cache.Add(query, torrents)
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
// fetchMagnet scrapes the nyaa view page to get the magnet link
|
||||
func (c *Client) fetchMagnet(viewURL string) (string, error) {
|
||||
if cached, ok := c.magnetCache.Get(viewURL); ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Get(viewURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("nyaa returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find magnet link in page
|
||||
// Pattern: href="magnet:?xt=urn:btih:..."
|
||||
magnetRe := regexp.MustCompile(`href="(magnet:\?xt=urn:btih:[^"]+)"`)
|
||||
matches := magnetRe.FindSubmatch(body)
|
||||
if len(matches) < 2 {
|
||||
return "", fmt.Errorf("magnet link not found")
|
||||
}
|
||||
|
||||
magnet := string(matches[1])
|
||||
c.magnetCache.Add(viewURL, magnet)
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
// extractViewURL converts download URL to view URL
|
||||
func extractViewURL(downloadURL string) string {
|
||||
// https://nyaa.si/download/1234567.torrent -> https://nyaa.si/view/1234567
|
||||
re := regexp.MustCompile(`/download/(\d+)\.torrent`)
|
||||
matches := re.FindStringSubmatch(downloadURL)
|
||||
if len(matches) < 2 {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("https://nyaa.si/view/%s", matches[1])
|
||||
}
|
||||
|
||||
// SearchEpisode searches for a specific episode of an anime
|
||||
func (c *Client) SearchEpisode(animeTitle string, episode int) ([]Torrent, error) {
|
||||
// Format episode number with leading zero for single digits
|
||||
epStr := fmt.Sprintf("%02d", episode)
|
||||
query := fmt.Sprintf("%s %s", animeTitle, epStr)
|
||||
|
||||
torrents, err := c.SearchAnime(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Strict filter: only return torrents that exactly match the episode
|
||||
var filtered []Torrent
|
||||
for _, t := range torrents {
|
||||
parsedEp := t.Episode
|
||||
if parsedEp == 0 {
|
||||
// Re-parse to be sure
|
||||
parsedEp = parseEpisodeNumber(t.Title)
|
||||
}
|
||||
if parsedEp == episode {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
// If strict filtering returns nothing, try a more specific search
|
||||
if len(filtered) == 0 {
|
||||
// Try with episode marker formats
|
||||
altQueries := []string{
|
||||
fmt.Sprintf("%s E%02d", animeTitle, episode),
|
||||
fmt.Sprintf("%s - %02d", animeTitle, episode),
|
||||
fmt.Sprintf("%s Episode %d", animeTitle, episode),
|
||||
}
|
||||
|
||||
for _, altQuery := range altQueries {
|
||||
altTorrents, err := c.SearchAnime(altQuery)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, t := range altTorrents {
|
||||
parsedEp := parseEpisodeNumber(t.Title)
|
||||
if parsedEp == episode {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
if len(filtered) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by magnet link
|
||||
seen := make(map[string]bool)
|
||||
var deduped []Torrent
|
||||
for _, t := range filtered {
|
||||
if t.Magnet != "" && !seen[t.Magnet] {
|
||||
seen[t.Magnet] = true
|
||||
deduped = append(deduped, t)
|
||||
} else if t.Magnet == "" && !seen[t.ViewURL] {
|
||||
seen[t.ViewURL] = true
|
||||
deduped = append(deduped, t)
|
||||
}
|
||||
}
|
||||
|
||||
return deduped, nil
|
||||
}
|
||||
|
||||
// parseEpisodeNumber tries to extract episode number from torrent title
|
||||
func parseEpisodeNumber(title string) int {
|
||||
// First, check if this is a batch/complete series (should return 0)
|
||||
batchPatterns := []string{
|
||||
`(?i)\b(batch|complete|全話|全\d+話)\b`,
|
||||
`(?i)\b(\d{1,4})\s*[-~]\s*(\d{1,4})\b`, // Range like 01-12 or 01~24
|
||||
}
|
||||
for _, p := range batchPatterns {
|
||||
if regexp.MustCompile(p).MatchString(title) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Patterns ordered by specificity (most specific first)
|
||||
patterns := []*regexp.Regexp{
|
||||
// S01E01 format (most specific)
|
||||
regexp.MustCompile(`(?i)S\d{1,2}E(\d{1,4})(?:v\d)?(?:\s|\[|\]|$)`),
|
||||
// Episode 01 format
|
||||
regexp.MustCompile(`(?i)Episode\s*(\d{1,4})(?:v\d)?(?:\s|\[|\]|$)`),
|
||||
// - 01 format (common in fansubs like [SubGroup] Anime - 01)
|
||||
regexp.MustCompile(`[-–]\s*(\d{1,4})(?:v\d)?(?:\s|\[|\]|$)`),
|
||||
// E01 format (standalone)
|
||||
regexp.MustCompile(`(?i)\bE(\d{1,4})(?:v\d)?(?:\s|\[|\]|$)`),
|
||||
// #01 format
|
||||
regexp.MustCompile(`#(\d{1,4})(?:v\d)?(?:\s|\[|\]|$)`),
|
||||
}
|
||||
|
||||
for _, re := range patterns {
|
||||
if matches := re.FindStringSubmatch(title); len(matches) > 1 {
|
||||
ep, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
// Sanity check: episode numbers are typically < 2000
|
||||
// This filters out years (2024) and resolutions (1920)
|
||||
if ep > 0 && ep < 2000 {
|
||||
return ep
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// FilterByQuality returns torrents matching the quality preference
|
||||
func FilterByQuality(torrents []Torrent, quality string) []Torrent {
|
||||
if quality == "" {
|
||||
return torrents
|
||||
}
|
||||
|
||||
var filtered []Torrent
|
||||
qualityLower := strings.ToLower(quality)
|
||||
|
||||
for _, t := range torrents {
|
||||
titleLower := strings.ToLower(t.Title)
|
||||
if strings.Contains(titleLower, qualityLower) {
|
||||
filtered = append(filtered, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return torrents
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// BestTorrent returns the torrent with the most seeders that has a magnet
|
||||
func BestTorrent(torrents []Torrent) *Torrent {
|
||||
if len(torrents) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var best *Torrent
|
||||
for i := range torrents {
|
||||
if torrents[i].Magnet == "" {
|
||||
continue
|
||||
}
|
||||
if best == nil || torrents[i].Seeders > best.Seeders {
|
||||
best = &torrents[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first with magnet
|
||||
if best == nil {
|
||||
for i := range torrents {
|
||||
if torrents[i].Magnet != "" {
|
||||
return &torrents[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package nyaa
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseEpisodeNumber(t *testing.T) {
|
||||
tests := []struct {
|
||||
title string
|
||||
expected int
|
||||
}{
|
||||
// Standard fansub formats
|
||||
{"[SubsPlease] Naruto - 01 (1080p) [ABC123].mkv", 1},
|
||||
{"[SubsPlease] Naruto - 220 (1080p) [ABC123].mkv", 220},
|
||||
{"[Erai-raws] One Piece - 1100 [1080p][Multiple Subtitle].mkv", 1100},
|
||||
|
||||
// S01E01 format
|
||||
{"Naruto S01E01 1080p WEB-DL", 1},
|
||||
{"Attack on Titan S04E28 Final", 28},
|
||||
|
||||
// Episode keyword
|
||||
{"Naruto Episode 1 [1080p]", 1},
|
||||
{"One Piece Episode 1089", 1089},
|
||||
|
||||
// E01 format
|
||||
{"Naruto E01 [1080p]", 1},
|
||||
{"Bleach E366 Final", 366},
|
||||
|
||||
// Hash/number format
|
||||
{"Anime Title #42 [720p]", 42},
|
||||
|
||||
// Should NOT match these (batches/complete)
|
||||
{"[SubsPlease] Naruto (01-220) [Batch]", 0},
|
||||
{"Naruto Complete Series 1-220", 0},
|
||||
{"Naruto Batch 01~220", 0},
|
||||
{"[Erai-raws] Death Note - 01 ~ 37 [1080p][BATCH][Multiple Subtitle]", 0},
|
||||
{"[SubGroup] Anime - 01-12 [BD][1080p]", 0},
|
||||
{"Show Name 01 - 24 Complete", 0},
|
||||
|
||||
// Should NOT match resolutions/years as episodes
|
||||
{"Naruto The Movie 2024 [1080p]", 0},
|
||||
{"[1920x1080] Naruto Remastered", 0},
|
||||
|
||||
// Version numbers should be stripped
|
||||
{"[SubsPlease] Naruto - 05v2 (1080p)", 5},
|
||||
{"Bleach - 100v3 [720p]", 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.title, func(t *testing.T) {
|
||||
got := parseEpisodeNumber(tt.title)
|
||||
if got != tt.expected {
|
||||
t.Errorf("parseEpisodeNumber(%q) = %d, want %d", tt.title, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"mal/internal/database"
|
||||
"mal/internal/features/anime"
|
||||
@@ -11,7 +9,6 @@ import (
|
||||
"mal/internal/features/watchlist"
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/shared/middleware"
|
||||
"mal/internal/streaming"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -31,13 +28,6 @@ func NewRouter(cfg Config) http.Handler {
|
||||
animeSvc := anime.NewService(cfg.JikanClient, cfg.DB)
|
||||
animeHandler := anime.NewHandler(animeSvc)
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
streamSvc, err := streaming.NewService(logger)
|
||||
if err != nil {
|
||||
panic("failed to initialize streaming service: " + err.Error())
|
||||
}
|
||||
streamHandler := streaming.NewHandler(streamSvc, cfg.JikanClient)
|
||||
|
||||
// Serve static files
|
||||
fs := http.FileServer(http.Dir("./static"))
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", fs))
|
||||
@@ -49,8 +39,7 @@ func NewRouter(cfg Config) http.Handler {
|
||||
mux.HandleFunc("/api/search-quick", animeHandler.HandleQuickSearch)
|
||||
mux.HandleFunc("/api/catalog", animeHandler.HandleAPICatalog)
|
||||
mux.HandleFunc("/anime/", animeHandler.HandleAnimeDetails)
|
||||
mux.HandleFunc("/api/anime/{id}/relations", animeHandler.HandleAPIAnimeRelations)
|
||||
mux.HandleFunc("/api/anime/{id}/episodes", animeHandler.HandleAPIAnimeEpisodes)
|
||||
mux.HandleFunc("/api/anime/", animeHandler.HandleAPIAnimeRelations)
|
||||
|
||||
// Auth Endpoints
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -62,9 +51,6 @@ func NewRouter(cfg Config) http.Handler {
|
||||
})
|
||||
mux.HandleFunc("/logout", authHandler.HandleLogout)
|
||||
|
||||
// Streaming endpoints
|
||||
streamHandler.RegisterRoutes(mux)
|
||||
|
||||
// Watchlist POST endpoint (Protected)
|
||||
mux.Handle("/api/watchlist/export", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleExportWatchlist)))
|
||||
mux.Handle("/api/watchlist/import", middleware.RequireAuth(http.HandlerFunc(watchlistHandler.HandleImportWatchlist)))
|
||||
|
||||
@@ -61,12 +61,10 @@ func RequireAuth(next http.Handler) http.Handler {
|
||||
// RequireGlobalAuth ensures that a valid user is in the context for all routes except login and static
|
||||
func RequireGlobalAuth(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Allow unauthenticated access to login, search, static files, and streaming
|
||||
// Allow unauthenticated access to login, search, and static files
|
||||
if r.URL.Path == "/login" || strings.HasPrefix(r.URL.Path, "/static/") ||
|
||||
r.URL.Path == "/search" || r.URL.Path == "/api/search" || r.URL.Path == "/api/search-quick" ||
|
||||
r.URL.Path == "/" ||
|
||||
strings.HasPrefix(r.URL.Path, "/watch/") || strings.HasPrefix(r.URL.Path, "/api/stream/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/anime/") || strings.HasPrefix(r.URL.Path, "/api/anime/") {
|
||||
r.URL.Path == "/" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,373 +0,0 @@
|
||||
package streaming
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"mal/internal/jikan"
|
||||
"mal/internal/nyaa"
|
||||
"mal/internal/templates"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
jikan *jikan.Client
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service, jikanClient *jikan.Client) *Handler {
|
||||
return &Handler{svc: svc, jikan: jikanClient}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
|
||||
// Watch page
|
||||
mux.HandleFunc("GET /watch/{animeID}/{episode}", h.HandleWatchPage)
|
||||
|
||||
// Search endpoints
|
||||
mux.HandleFunc("GET /api/stream/search", h.HandleSearch)
|
||||
mux.HandleFunc("GET /api/stream/search/episode", h.HandleSearchEpisode)
|
||||
mux.HandleFunc("GET /api/stream/search-htmx", h.HandleSearchHTMX)
|
||||
|
||||
// Streaming endpoints
|
||||
mux.HandleFunc("POST /api/stream/start", h.HandleStartStream)
|
||||
mux.HandleFunc("GET /api/stream/video/{hash}", h.HandleStreamVideo)
|
||||
mux.HandleFunc("GET /api/stream/info/{hash}", h.HandleStreamInfo)
|
||||
mux.HandleFunc("DELETE /api/stream/{hash}", h.HandleDropStream)
|
||||
|
||||
// HLS endpoints
|
||||
mux.HandleFunc("POST /api/stream/hls/{hash}", h.HandleStartHLS)
|
||||
mux.HandleFunc("GET /api/stream/hls/{hash}/playlist.m3u8", h.HandleHLSPlaylist)
|
||||
mux.HandleFunc("GET /api/stream/hls/{hash}/{segment}", h.HandleHLSSegment)
|
||||
}
|
||||
|
||||
// HandleWatchPage renders the video player page for an episode
|
||||
func (h *Handler) HandleWatchPage(w http.ResponseWriter, r *http.Request) {
|
||||
animeIDStr := r.PathValue("animeID")
|
||||
episodeStr := r.PathValue("episode")
|
||||
|
||||
animeID, err := strconv.Atoi(animeIDStr)
|
||||
if err != nil || animeID <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
episode, err := strconv.Atoi(episodeStr)
|
||||
if err != nil || episode <= 0 {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
anime, err := h.jikan.GetAnimeByID(animeID)
|
||||
if err != nil {
|
||||
log.Printf("failed to get anime %d: %v", animeID, err)
|
||||
http.Error(w, "Failed to fetch anime", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build list of title variations to try
|
||||
// Fansubs typically use English titles or romaji
|
||||
var titles []string
|
||||
if anime.TitleEnglish != "" {
|
||||
titles = append(titles, anime.TitleEnglish)
|
||||
}
|
||||
titles = append(titles, anime.Title) // Usually romaji
|
||||
titles = append(titles, anime.TitleSynonyms...)
|
||||
|
||||
// Search using title variations until we find results
|
||||
var torrents []nyaa.Torrent
|
||||
for _, title := range titles {
|
||||
torrents, err = h.svc.SearchEpisode(title, episode)
|
||||
if err != nil {
|
||||
log.Printf("torrent search error for %q: %v", title, err)
|
||||
continue
|
||||
}
|
||||
if len(torrents) > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Filter to 1080p by default, fallback to all
|
||||
filtered := nyaa.FilterByQuality(torrents, "1080p")
|
||||
if len(filtered) == 0 {
|
||||
filtered = torrents
|
||||
}
|
||||
|
||||
// Limit to top 10
|
||||
if len(filtered) > 10 {
|
||||
filtered = filtered[:10]
|
||||
}
|
||||
|
||||
templates.WatchPage(anime, episode, filtered).Render(r.Context(), w)
|
||||
}
|
||||
|
||||
// HandleSearch searches nyaa for anime torrents
|
||||
func (h *Handler) HandleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
http.Error(w, "query required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
torrents, err := h.svc.SearchAnime(query)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
quality := r.URL.Query().Get("quality")
|
||||
if quality != "" {
|
||||
torrents = nyaa.FilterByQuality(torrents, quality)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(torrents)
|
||||
}
|
||||
|
||||
// HandleSearchEpisode searches nyaa for a specific episode
|
||||
func (h *Handler) HandleSearchEpisode(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.URL.Query().Get("title")
|
||||
episodeStr := r.URL.Query().Get("episode")
|
||||
|
||||
if title == "" || episodeStr == "" {
|
||||
http.Error(w, "title and episode required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
episode, err := strconv.Atoi(episodeStr)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid episode number", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
torrents, err := h.svc.SearchEpisode(title, episode)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
quality := r.URL.Query().Get("quality")
|
||||
if quality != "" {
|
||||
torrents = nyaa.FilterByQuality(torrents, quality)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(torrents)
|
||||
}
|
||||
|
||||
type startStreamRequest struct {
|
||||
Magnet string `json:"magnet"`
|
||||
}
|
||||
|
||||
type startStreamResponse struct {
|
||||
InfoHash string `json:"info_hash"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// HandleStartStream starts streaming a torrent from a magnet link
|
||||
func (h *Handler) HandleStartStream(w http.ResponseWriter, r *http.Request) {
|
||||
var req startStreamRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Magnet == "" {
|
||||
http.Error(w, "magnet required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.svc.AddMagnet(r.Context(), req.Magnet)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(startStreamResponse{
|
||||
InfoHash: info.InfoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Size,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleStreamVideo streams the video file from a torrent
|
||||
func (h *Handler) HandleStreamVideo(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "hash required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.StreamVideo(w, r, hash); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// HandleStreamInfo returns info about an active stream
|
||||
func (h *Handler) HandleStreamInfo(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "hash required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
info, err := h.svc.GetStreamInfo(hash)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(info)
|
||||
}
|
||||
|
||||
// HandleDropStream stops and removes a stream
|
||||
func (h *Handler) HandleDropStream(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "hash required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
h.svc.DropTorrent(hash)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// TorrentResult for HTMX responses
|
||||
type TorrentResult struct {
|
||||
Torrents []nyaa.Torrent
|
||||
Query string
|
||||
Episode int
|
||||
}
|
||||
|
||||
// HandleSearchHTMX returns HTML for torrent search results
|
||||
func (h *Handler) HandleSearchHTMX(w http.ResponseWriter, r *http.Request) {
|
||||
query := r.URL.Query().Get("q")
|
||||
if query == "" {
|
||||
fmt.Fprint(w, `<div class="search-empty">enter a search query</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
torrents, err := h.svc.SearchAnime(query)
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, `<div class="search-error">error: %s</div>`, html.EscapeString(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
quality := r.URL.Query().Get("quality")
|
||||
if quality != "" {
|
||||
torrents = nyaa.FilterByQuality(torrents, quality)
|
||||
}
|
||||
|
||||
// Filter to only torrents with magnet links
|
||||
var withMagnets []nyaa.Torrent
|
||||
for _, t := range torrents {
|
||||
if t.Magnet != "" {
|
||||
withMagnets = append(withMagnets, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(withMagnets) == 0 {
|
||||
fmt.Fprint(w, `<div class="search-empty">no torrents found (or no magnet links available)</div>`)
|
||||
return
|
||||
}
|
||||
|
||||
// Return simple HTML list
|
||||
fmt.Fprint(w, `<div class="torrent-list">`)
|
||||
for _, t := range withMagnets {
|
||||
fmt.Fprintf(w, `
|
||||
<div class="torrent-item" data-magnet="%s">
|
||||
<div class="torrent-title">%s</div>
|
||||
<div class="torrent-meta">
|
||||
<span class="torrent-size">%s</span>
|
||||
<span class="torrent-seeds">%d seeds</span>
|
||||
</div>
|
||||
</div>
|
||||
`, html.EscapeString(t.Magnet), html.EscapeString(t.Title), html.EscapeString(t.Size), t.Seeders)
|
||||
}
|
||||
fmt.Fprint(w, `</div>`)
|
||||
}
|
||||
|
||||
// HandleStartHLS starts HLS transcoding for a torrent
|
||||
func (h *Handler) HandleStartHLS(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "hash required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
epStr := r.URL.Query().Get("ep")
|
||||
episode, _ := strconv.Atoi(epStr)
|
||||
|
||||
// Check torrent exists
|
||||
if _, ok := h.svc.GetTorrent(hash); !ok {
|
||||
http.Error(w, "torrent not found - start stream first", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.svc.StartHLS(r.Context(), hash, episode)
|
||||
if err != nil {
|
||||
log.Printf("HLS start error: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Use sessionKey for subsequent requests
|
||||
sessionKey := hash
|
||||
if episode > 0 {
|
||||
sessionKey = fmt.Sprintf("%s-ep%d", hash, episode)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"playlist": fmt.Sprintf("/api/stream/hls/%s/playlist.m3u8", sessionKey),
|
||||
"status": "ready",
|
||||
"output": session.OutputDir,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleHLSPlaylist serves the HLS playlist file
|
||||
func (h *Handler) HandleHLSPlaylist(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "hash required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := h.svc.GetHLSSession(hash)
|
||||
if !ok {
|
||||
http.Error(w, "HLS session not found - start transcoding first", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/vnd.apple.mpegurl")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
http.ServeFile(w, r, session.Playlist)
|
||||
}
|
||||
|
||||
// HandleHLSSegment serves an HLS segment file
|
||||
func (h *Handler) HandleHLSSegment(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.PathValue("hash")
|
||||
segment := r.PathValue("segment")
|
||||
|
||||
if hash == "" || segment == "" {
|
||||
http.Error(w, "hash and segment required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
session, ok := h.svc.GetHLSSession(hash)
|
||||
if !ok {
|
||||
http.Error(w, "HLS session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Serve the segment file
|
||||
segmentPath := fmt.Sprintf("%s/%s", session.OutputDir, segment)
|
||||
w.Header().Set("Content-Type", "video/mp2t")
|
||||
http.ServeFile(w, r, segmentPath)
|
||||
}
|
||||
@@ -1,248 +0,0 @@
|
||||
package streaming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HLSTranscoder manages on-demand HLS transcoding sessions
|
||||
type HLSTranscoder struct {
|
||||
logger *slog.Logger
|
||||
sessions map[string]*HLSSession
|
||||
mu sync.RWMutex
|
||||
baseDir string
|
||||
}
|
||||
|
||||
// HLSSession represents an active transcoding session
|
||||
type HLSSession struct {
|
||||
InfoHash string
|
||||
OutputDir string
|
||||
Playlist string
|
||||
cmd *exec.Cmd
|
||||
cancel context.CancelFunc
|
||||
ready chan struct{}
|
||||
err error
|
||||
lastAccess time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewHLSTranscoder creates a new HLS transcoder
|
||||
func NewHLSTranscoder(logger *slog.Logger) (*HLSTranscoder, error) {
|
||||
// Check ffmpeg is available
|
||||
if _, err := exec.LookPath("ffmpeg"); err != nil {
|
||||
return nil, fmt.Errorf("ffmpeg not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
baseDir := filepath.Join(os.TempDir(), "mal-hls")
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create HLS temp dir: %w", err)
|
||||
}
|
||||
|
||||
t := &HLSTranscoder{
|
||||
logger: logger,
|
||||
sessions: make(map[string]*HLSSession),
|
||||
baseDir: baseDir,
|
||||
}
|
||||
|
||||
// Start cleanup goroutine
|
||||
go t.cleanupLoop()
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// StartSessionWithReader starts transcoding from a reader (piped input) to HLS
|
||||
func (t *HLSTranscoder) StartSessionWithReader(infoHash string, reader io.Reader) (*HLSSession, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
// Return existing session if available
|
||||
if session, ok := t.sessions[infoHash]; ok {
|
||||
session.mu.Lock()
|
||||
session.lastAccess = time.Now()
|
||||
session.mu.Unlock()
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Create output directory
|
||||
outputDir := filepath.Join(t.baseDir, infoHash)
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create output dir: %w", err)
|
||||
}
|
||||
|
||||
playlist := filepath.Join(outputDir, "stream.m3u8")
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
session := &HLSSession{
|
||||
InfoHash: infoHash,
|
||||
OutputDir: outputDir,
|
||||
Playlist: playlist,
|
||||
cancel: cancel,
|
||||
ready: make(chan struct{}),
|
||||
lastAccess: time.Now(),
|
||||
}
|
||||
|
||||
// Start ffmpeg with pipe input
|
||||
// Use browser-compatible H.264 baseline/main profile and AAC-LC
|
||||
cmd := exec.CommandContext(ctx, "ffmpeg",
|
||||
"-i", "pipe:0", // Read from stdin
|
||||
// Video: H.264 with browser-compatible settings
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
"-crf", "23",
|
||||
"-profile:v", "main",
|
||||
"-level", "4.0",
|
||||
"-pix_fmt", "yuv420p",
|
||||
// Audio: AAC-LC stereo
|
||||
"-c:a", "aac",
|
||||
"-b:a", "128k",
|
||||
"-ac", "2",
|
||||
"-ar", "44100",
|
||||
// HLS output
|
||||
"-f", "hls",
|
||||
"-hls_time", "4",
|
||||
"-hls_list_size", "0",
|
||||
"-hls_flags", "append_list+independent_segments",
|
||||
"-hls_segment_type", "mpegts",
|
||||
"-hls_segment_filename", filepath.Join(outputDir, "segment_%03d.ts"),
|
||||
"-start_number", "0",
|
||||
playlist,
|
||||
)
|
||||
|
||||
// Pipe the reader to ffmpeg's stdin
|
||||
cmd.Stdin = reader
|
||||
session.cmd = cmd
|
||||
|
||||
// Capture stderr for debugging
|
||||
cmd.Stderr = &ffmpegLogger{logger: t.logger, infoHash: infoHash}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
cancel()
|
||||
os.RemoveAll(outputDir)
|
||||
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||
}
|
||||
|
||||
t.sessions[infoHash] = session
|
||||
|
||||
// Wait for playlist to be created
|
||||
go func() {
|
||||
defer close(session.ready)
|
||||
|
||||
for i := 0; i < 120; i++ { // Wait up to 60 seconds
|
||||
if _, err := os.Stat(playlist); err == nil {
|
||||
// Check if at least one segment exists
|
||||
segments, _ := filepath.Glob(filepath.Join(outputDir, "segment_*.ts"))
|
||||
if len(segments) > 0 {
|
||||
t.logger.Info("HLS segments ready", "hash", infoHash, "segments", len(segments))
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
session.err = fmt.Errorf("timeout waiting for HLS segments")
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Monitor process
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
if err != nil && ctx.Err() == nil {
|
||||
t.logger.Error("ffmpeg exited with error", "hash", infoHash, "error", err)
|
||||
} else {
|
||||
t.logger.Info("ffmpeg finished", "hash", infoHash)
|
||||
}
|
||||
}()
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetSession returns an existing session
|
||||
func (t *HLSTranscoder) GetSession(infoHash string) (*HLSSession, bool) {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
session, ok := t.sessions[infoHash]
|
||||
if ok {
|
||||
session.mu.Lock()
|
||||
session.lastAccess = time.Now()
|
||||
session.mu.Unlock()
|
||||
}
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// StopSession stops a transcoding session
|
||||
func (t *HLSTranscoder) StopSession(infoHash string) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
if session, ok := t.sessions[infoHash]; ok {
|
||||
session.cancel()
|
||||
os.RemoveAll(session.OutputDir)
|
||||
delete(t.sessions, infoHash)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitReady waits for the session to have segments ready
|
||||
func (s *HLSSession) WaitReady(ctx context.Context) error {
|
||||
select {
|
||||
case <-s.ready:
|
||||
return s.err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupLoop removes stale sessions
|
||||
func (t *HLSTranscoder) cleanupLoop() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
t.mu.Lock()
|
||||
now := time.Now()
|
||||
for hash, session := range t.sessions {
|
||||
session.mu.Lock()
|
||||
if now.Sub(session.lastAccess) > 10*time.Minute {
|
||||
session.cancel()
|
||||
os.RemoveAll(session.OutputDir)
|
||||
delete(t.sessions, hash)
|
||||
t.logger.Info("cleaned up stale HLS session", "hash", hash)
|
||||
}
|
||||
session.mu.Unlock()
|
||||
}
|
||||
t.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown stops all sessions
|
||||
func (t *HLSTranscoder) Shutdown() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
for hash, session := range t.sessions {
|
||||
session.cancel()
|
||||
os.RemoveAll(session.OutputDir)
|
||||
delete(t.sessions, hash)
|
||||
}
|
||||
|
||||
os.RemoveAll(t.baseDir)
|
||||
}
|
||||
|
||||
// ffmpegLogger logs ffmpeg stderr output
|
||||
type ffmpegLogger struct {
|
||||
logger *slog.Logger
|
||||
infoHash string
|
||||
}
|
||||
|
||||
func (l *ffmpegLogger) Write(p []byte) (n int, err error) {
|
||||
l.logger.Debug("ffmpeg", "hash", l.infoHash, "output", string(p))
|
||||
return len(p), nil
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
package streaming
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/anacrolix/torrent"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"golang.org/x/net/proxy"
|
||||
|
||||
"mal/internal/nyaa"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
client *torrent.Client
|
||||
nyaa *nyaa.Client
|
||||
hls *HLSTranscoder
|
||||
mu sync.RWMutex
|
||||
activeTorrents map[string]*torrent.Torrent
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
type StreamInfo struct {
|
||||
InfoHash string
|
||||
Name string
|
||||
Size int64
|
||||
Files []FileInfo
|
||||
Progress float64
|
||||
DownloadRate int64
|
||||
Peers int
|
||||
}
|
||||
|
||||
type FileInfo struct {
|
||||
Index int
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
func NewService(logger *slog.Logger) (*Service, error) {
|
||||
cfg := torrent.NewDefaultClientConfig()
|
||||
cfg.ListenPort = 42069
|
||||
cfg.Seed = false
|
||||
cfg.NoUpload = true
|
||||
|
||||
// Use temp directory for downloads
|
||||
cfg.DataDir = filepath.Join("/tmp", "mal-streams")
|
||||
|
||||
// Configure SOCKS5 proxy if TORRENT_PROXY is set
|
||||
// Usage: export TORRENT_PROXY=socks5://127.0.0.1:1080
|
||||
// Start with: ssh -D 1080 -N user@your-vps
|
||||
if proxyURL := os.Getenv("TORRENT_PROXY"); proxyURL != "" {
|
||||
parsed, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid TORRENT_PROXY url: %w", err)
|
||||
}
|
||||
|
||||
dialer, err := proxy.FromURL(parsed, proxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create proxy dialer: %w", err)
|
||||
}
|
||||
|
||||
// Proxy HTTP requests (trackers, webseeds)
|
||||
cfg.HTTPProxy = func(*http.Request) (*url.URL, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
// Proxy peer connections via DialContext
|
||||
if contextDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
cfg.HTTPDialContext = contextDialer.DialContext
|
||||
}
|
||||
|
||||
logger.Info("torrent proxy configured", "proxy", proxyURL)
|
||||
}
|
||||
|
||||
client, err := torrent.NewClient(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create torrent client: %w", err)
|
||||
}
|
||||
|
||||
hls, err := NewHLSTranscoder(logger)
|
||||
if err != nil {
|
||||
logger.Warn("HLS transcoding unavailable", "error", err)
|
||||
// Continue without HLS - will fall back to direct streaming
|
||||
}
|
||||
|
||||
return &Service{
|
||||
client: client,
|
||||
nyaa: nyaa.NewClient(),
|
||||
hls: hls,
|
||||
activeTorrents: make(map[string]*torrent.Torrent),
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchEpisode searches nyaa for torrents of a specific episode
|
||||
func (s *Service) SearchEpisode(animeTitle string, episode int) ([]nyaa.Torrent, error) {
|
||||
return s.nyaa.SearchEpisode(animeTitle, episode)
|
||||
}
|
||||
|
||||
// SearchAnime searches nyaa for torrents of an anime
|
||||
func (s *Service) SearchAnime(query string) ([]nyaa.Torrent, error) {
|
||||
return s.nyaa.SearchAnime(query)
|
||||
}
|
||||
|
||||
// AddMagnet adds a magnet link and returns stream info
|
||||
func (s *Service) AddMagnet(ctx context.Context, magnetURI string) (*StreamInfo, error) {
|
||||
t, err := s.client.AddMagnet(magnetURI)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add magnet: %w", err)
|
||||
}
|
||||
|
||||
// Wait for metadata with timeout
|
||||
select {
|
||||
case <-t.GotInfo():
|
||||
case <-ctx.Done():
|
||||
t.Drop()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(60 * time.Second):
|
||||
t.Drop()
|
||||
return nil, fmt.Errorf("timeout waiting for torrent metadata")
|
||||
}
|
||||
|
||||
infoHash := t.InfoHash().HexString()
|
||||
|
||||
s.mu.Lock()
|
||||
s.activeTorrents[infoHash] = t
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.getStreamInfo(t), nil
|
||||
}
|
||||
|
||||
// GetTorrent returns an active torrent by info hash
|
||||
func (s *Service) GetTorrent(infoHash string) (*torrent.Torrent, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
t, ok := s.activeTorrents[infoHash]
|
||||
return t, ok
|
||||
}
|
||||
|
||||
// StreamFile streams a specific file from a torrent
|
||||
func (s *Service) StreamFile(w http.ResponseWriter, r *http.Request, infoHash string, fileIdx int) error {
|
||||
t, ok := s.GetTorrent(infoHash)
|
||||
if !ok {
|
||||
return fmt.Errorf("torrent not found: %s", infoHash)
|
||||
}
|
||||
|
||||
files := t.Files()
|
||||
if fileIdx < 0 || fileIdx >= len(files) {
|
||||
return fmt.Errorf("invalid file index: %d", fileIdx)
|
||||
}
|
||||
|
||||
file := files[fileIdx]
|
||||
reader := file.NewReader()
|
||||
reader.SetReadahead(file.Length() / 100) // 1% readahead
|
||||
reader.SetResponsive()
|
||||
|
||||
// Determine content type
|
||||
contentType := "application/octet-stream"
|
||||
ext := strings.ToLower(filepath.Ext(file.Path()))
|
||||
switch ext {
|
||||
case ".mp4":
|
||||
contentType = "video/mp4"
|
||||
case ".mkv":
|
||||
contentType = "video/x-matroska"
|
||||
case ".webm":
|
||||
contentType = "video/webm"
|
||||
case ".avi":
|
||||
contentType = "video/x-msvideo"
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
|
||||
// Handle range requests for seeking
|
||||
http.ServeContent(w, r, file.Path(), time.Time{}, reader)
|
||||
return nil
|
||||
}
|
||||
|
||||
// StreamVideo finds and streams the main video file from a torrent
|
||||
func (s *Service) StreamVideo(w http.ResponseWriter, r *http.Request, infoHash string) error {
|
||||
t, ok := s.GetTorrent(infoHash)
|
||||
if !ok {
|
||||
return fmt.Errorf("torrent not found: %s", infoHash)
|
||||
}
|
||||
|
||||
// Find the largest video file
|
||||
var bestFile *torrent.File
|
||||
var bestIdx int
|
||||
for i, f := range t.Files() {
|
||||
if isVideoFile(f.Path()) {
|
||||
if bestFile == nil || f.Length() > bestFile.Length() {
|
||||
bestFile = f
|
||||
bestIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestFile == nil {
|
||||
return fmt.Errorf("no video file found in torrent")
|
||||
}
|
||||
|
||||
return s.StreamFile(w, r, infoHash, bestIdx)
|
||||
}
|
||||
|
||||
// GetStreamInfo returns info about an active torrent
|
||||
func (s *Service) GetStreamInfo(infoHash string) (*StreamInfo, error) {
|
||||
t, ok := s.GetTorrent(infoHash)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("torrent not found: %s", infoHash)
|
||||
}
|
||||
return s.getStreamInfo(t), nil
|
||||
}
|
||||
|
||||
func (s *Service) getStreamInfo(t *torrent.Torrent) *StreamInfo {
|
||||
info := &StreamInfo{
|
||||
InfoHash: t.InfoHash().HexString(),
|
||||
Name: t.Name(),
|
||||
Peers: t.Stats().ActivePeers,
|
||||
}
|
||||
|
||||
var totalLength, completed int64
|
||||
for i, f := range t.Files() {
|
||||
info.Files = append(info.Files, FileInfo{
|
||||
Index: i,
|
||||
Path: f.Path(),
|
||||
Size: f.Length(),
|
||||
})
|
||||
totalLength += f.Length()
|
||||
completed += f.BytesCompleted()
|
||||
}
|
||||
|
||||
info.Size = totalLength
|
||||
if totalLength > 0 {
|
||||
info.Progress = float64(completed) / float64(totalLength) * 100
|
||||
}
|
||||
|
||||
stats := t.Stats()
|
||||
info.DownloadRate = stats.ConnStats.BytesReadData.Int64()
|
||||
|
||||
return info
|
||||
}
|
||||
|
||||
// DropTorrent removes a torrent from the client
|
||||
func (s *Service) DropTorrent(infoHash string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if t, ok := s.activeTorrents[infoHash]; ok {
|
||||
t.Drop()
|
||||
delete(s.activeTorrents, infoHash)
|
||||
}
|
||||
}
|
||||
|
||||
// Close shuts down the torrent client
|
||||
func (s *Service) Close() {
|
||||
s.mu.Lock()
|
||||
for _, t := range s.activeTorrents {
|
||||
t.Drop()
|
||||
}
|
||||
s.activeTorrents = nil
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.hls != nil {
|
||||
s.hls.Shutdown()
|
||||
}
|
||||
|
||||
s.client.Close()
|
||||
}
|
||||
|
||||
func isVideoFile(path string) bool {
|
||||
videoExts := []string{".mp4", ".mkv", ".avi", ".mov", ".webm", ".wmv", ".flv"}
|
||||
lower := strings.ToLower(path)
|
||||
for _, ext := range videoExts {
|
||||
if strings.HasSuffix(lower, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findEpisodeFile finds the video file matching a specific episode number
|
||||
// Falls back to largest video file if no match found
|
||||
func findEpisodeFile(files []*torrent.File, episode int) *torrent.File {
|
||||
var bestMatch *torrent.File
|
||||
var fallback *torrent.File
|
||||
|
||||
// Episode patterns to match in filenames
|
||||
epStr := fmt.Sprintf("%02d", episode)
|
||||
epStr2 := fmt.Sprintf("%d", episode)
|
||||
|
||||
patterns := []string{
|
||||
fmt.Sprintf(" - %s", epStr), // - 01
|
||||
fmt.Sprintf(" - %s ", epStr), // - 01 (with space after)
|
||||
fmt.Sprintf("E%s", epStr), // E01
|
||||
fmt.Sprintf("E%s ", epStr), // E01 (with space)
|
||||
fmt.Sprintf("Episode %s", epStr2), // Episode 1
|
||||
fmt.Sprintf("Episode %s", epStr), // Episode 01
|
||||
fmt.Sprintf(" %s ", epStr), // standalone 01
|
||||
fmt.Sprintf("[%s]", epStr), // [01]
|
||||
fmt.Sprintf("_%s_", epStr), // _01_
|
||||
fmt.Sprintf(".%s.", epStr), // .01.
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !isVideoFile(f.Path()) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Track largest video as fallback
|
||||
if fallback == nil || f.Length() > fallback.Length() {
|
||||
fallback = f
|
||||
}
|
||||
|
||||
filename := strings.ToLower(filepath.Base(f.Path()))
|
||||
|
||||
// Check each pattern
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(filename, strings.ToLower(pattern)) {
|
||||
// Verify it's not a different episode (e.g., searching for ep 1, don't match ep 10)
|
||||
// Check character after match isn't a digit
|
||||
idx := strings.Index(filename, strings.ToLower(pattern))
|
||||
if idx >= 0 {
|
||||
afterIdx := idx + len(pattern)
|
||||
if afterIdx >= len(filename) || !isDigit(filename[afterIdx]) {
|
||||
if bestMatch == nil || f.Length() > bestMatch.Length() {
|
||||
bestMatch = f
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestMatch != nil {
|
||||
return bestMatch
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9'
|
||||
}
|
||||
|
||||
// ParseMagnetHash extracts info hash from a magnet URI
|
||||
func ParseMagnetHash(magnetURI string) (string, error) {
|
||||
spec, err := torrent.TorrentSpecFromMagnetUri(magnetURI)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return spec.InfoHash.HexString(), nil
|
||||
}
|
||||
|
||||
// MagnetFromHash creates a minimal magnet URI from an info hash
|
||||
func MagnetFromHash(infoHash string) (string, error) {
|
||||
var ih metainfo.Hash
|
||||
if err := ih.FromHexString(infoHash); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("magnet:?xt=urn:btih:%s", ih.HexString()), nil
|
||||
}
|
||||
|
||||
// GetVideoFilePath returns the filesystem path to the main video file
|
||||
func (s *Service) GetVideoFilePath(infoHash string) (string, error) {
|
||||
t, ok := s.GetTorrent(infoHash)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("torrent not found: %s", infoHash)
|
||||
}
|
||||
|
||||
// Find the largest video file
|
||||
var bestFile *torrent.File
|
||||
for _, f := range t.Files() {
|
||||
if isVideoFile(f.Path()) {
|
||||
if bestFile == nil || f.Length() > bestFile.Length() {
|
||||
bestFile = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if bestFile == nil {
|
||||
return "", fmt.Errorf("no video file found in torrent")
|
||||
}
|
||||
|
||||
// Return path relative to data dir
|
||||
return filepath.Join("/tmp", "mal-streams", bestFile.Path()), nil
|
||||
}
|
||||
|
||||
// StartHLS starts HLS transcoding for a torrent
|
||||
// If episode > 0, it will try to find the file matching that episode number
|
||||
func (s *Service) StartHLS(ctx context.Context, infoHash string, episode int) (*HLSSession, error) {
|
||||
if s.hls == nil {
|
||||
return nil, fmt.Errorf("HLS transcoding not available (ffmpeg not found)")
|
||||
}
|
||||
|
||||
// Use episode-specific session key if episode specified
|
||||
sessionKey := infoHash
|
||||
if episode > 0 {
|
||||
sessionKey = fmt.Sprintf("%s-ep%d", infoHash, episode)
|
||||
}
|
||||
|
||||
// Check if session already exists
|
||||
if session, ok := s.hls.GetSession(sessionKey); ok {
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// Get torrent and video file
|
||||
t, ok := s.GetTorrent(infoHash)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("torrent not found: %s", infoHash)
|
||||
}
|
||||
|
||||
// Find the video file - either by episode or largest
|
||||
var videoFile *torrent.File
|
||||
if episode > 0 {
|
||||
videoFile = findEpisodeFile(t.Files(), episode)
|
||||
if videoFile != nil {
|
||||
s.logger.Info("found episode file", "episode", episode, "file", videoFile.Path())
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to largest video file
|
||||
if videoFile == nil {
|
||||
for _, f := range t.Files() {
|
||||
if isVideoFile(f.Path()) {
|
||||
if videoFile == nil || f.Length() > videoFile.Length() {
|
||||
videoFile = f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if videoFile == nil {
|
||||
return nil, fmt.Errorf("no video file found in torrent")
|
||||
}
|
||||
|
||||
// Prioritize downloading the beginning of the file for ffmpeg
|
||||
videoFile.Download()
|
||||
reader := videoFile.NewReader()
|
||||
reader.SetReadahead(10 * 1024 * 1024) // 10MB readahead
|
||||
reader.SetResponsive()
|
||||
|
||||
// Wait for at least 2MB to be available before starting ffmpeg
|
||||
minBytes := int64(2 * 1024 * 1024)
|
||||
s.logger.Info("waiting for initial data", "hash", infoHash, "file", videoFile.Path(), "need", minBytes)
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
timeout := time.After(60 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-timeout:
|
||||
return nil, fmt.Errorf("timeout waiting for video data")
|
||||
case <-ticker.C:
|
||||
completed := videoFile.BytesCompleted()
|
||||
if completed >= minBytes {
|
||||
s.logger.Info("got enough data, starting transcoding", "hash", infoHash, "bytes", completed)
|
||||
goto ready
|
||||
}
|
||||
s.logger.Debug("waiting for data", "hash", infoHash, "completed", completed, "need", minBytes)
|
||||
}
|
||||
}
|
||||
|
||||
ready:
|
||||
// Start transcoding with the reader piped to ffmpeg
|
||||
session, err := s.hls.StartSessionWithReader(sessionKey, reader)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for first segments to be ready
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := session.WaitReady(waitCtx); err != nil {
|
||||
s.hls.StopSession(sessionKey)
|
||||
return nil, fmt.Errorf("HLS not ready: %w", err)
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// GetHLSSession returns an existing HLS session
|
||||
func (s *Service) GetHLSSession(infoHash string) (*HLSSession, bool) {
|
||||
if s.hls == nil {
|
||||
return nil, false
|
||||
}
|
||||
return s.hls.GetSession(infoHash)
|
||||
}
|
||||
|
||||
// HasHLS returns whether HLS transcoding is available
|
||||
func (s *Service) HasHLS() bool {
|
||||
return s.hls != nil
|
||||
}
|
||||
@@ -56,14 +56,6 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
|
||||
<span>loading relations</span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="anime-episodes" hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes", anime.MalID))) } hx-trigger="load">
|
||||
<div class="loading-indicator">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<span>loading episodes</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<aside class="anime-sidebar">
|
||||
if anime.TitleJapanese != "" {
|
||||
|
||||
@@ -251,362 +251,349 @@ func AnimeDetails(anime jikan.Anime, currentStatus string) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading relations</span></div></section><section class=\"anime-episodes\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/anime/%d/episodes", anime.MalID))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 59, Col: 114}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading episodes</span></div></section></div><aside class=\"anime-sidebar\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" hx-trigger=\"load\"><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading relations</span></div></section></div><aside class=\"anime-sidebar\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if anime.TitleJapanese != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Japanese</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Japanese</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(anime.TitleJapanese)
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(anime.TitleJapanese)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 72, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 64, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.TitleSynonyms) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Synonyms</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Synonyms</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(anime.TitleSynonyms, ", "))
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(strings.Join(anime.TitleSynonyms, ", "))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 78, Col: 75}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 70, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Aired.String != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Aired</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Aired</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Aired.String)
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Aired.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 84, Col: 54}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 76, Col: 54}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Premiered() != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Premiered</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Premiered</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Premiered())
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Premiered())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 90, Col: 53}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 82, Col: 53}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Duration != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Duration</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Duration</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Duration)
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Duration)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 96, Col: 50}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 88, Col: 50}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Status != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Status</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Status</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Status)
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Status)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 102, Col: 48}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 94, Col: 48}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Score > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">MAL Score</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">MAL Score</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", anime.Score))
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2f", anime.Score))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 108, Col: 68}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 100, Col: 68}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Genres) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Genres</span><div class=\"sidebar-tags\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Genres</span><div class=\"sidebar-tags\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, g := range anime.Genres {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "<span class=\"sidebar-tag\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"sidebar-tag\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
var templ_7745c5c3_Var20 string
|
||||
templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.JoinStringErrs(g.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 116, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 108, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var20))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Studios) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Studios</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Studios</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Studios))
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Studios))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 124, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 116, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Producers) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Producers</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Producers</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Producers))
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(joinNames(anime.Producers))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 130, Col: 61}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 122, Col: 61}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Source != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Source</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Source</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Source)
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Source)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 136, Col: 47}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 128, Col: 47}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Demographics) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Demographics</span><div class=\"sidebar-tags\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Demographics</span><div class=\"sidebar-tags\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, d := range anime.Demographics {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "<span class=\"sidebar-tag\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<span class=\"sidebar-tag\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(d.Name)
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(d.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 144, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 136, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Themes) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Themes</span><div class=\"sidebar-tags\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "<div class=\"sidebar-row sidebar-row-wrap\"><span class=\"sidebar-label\">Themes</span><div class=\"sidebar-tags\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, t := range anime.Themes {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "<span class=\"sidebar-tag\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<span class=\"sidebar-tag\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(t.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 154, Col: 41}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 146, Col: 41}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if anime.Broadcast.String != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Broadcast</span> <span class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Broadcast</span> <span class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Broadcast.String)
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(anime.Broadcast.String)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 57}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 154, Col: 57}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</span></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</span></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(anime.Streaming) > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Streaming</span><div class=\"sidebar-value\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<div class=\"sidebar-row\"><span class=\"sidebar-label\">Streaming</span><div class=\"sidebar-value\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, s := range anime.Streaming {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "<div><a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "<div><a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 templ.SafeURL
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(s.URL))
|
||||
var templ_7745c5c3_Var27 templ.SafeURL
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(s.URL))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 170, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" target=\"_blank\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 162, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\" target=\"_blank\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var29 string
|
||||
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(s.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 170, Col: 65}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</a></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "</aside></div><script>\n\t\t\tfunction toggleDropdown() {\n\t\t\t\tdocument.getElementById('watchlist-dropdown').classList.toggle('open');\n\t\t\t}\n\t\t\tdocument.addEventListener('click', function(e) {\n\t\t\t\tconst dropdown = document.getElementById('watchlist-dropdown');\n\t\t\t\tif (dropdown && !dropdown.contains(e.target)) {\n\t\t\t\t\tdropdown.classList.remove('open');\n\t\t\t\t}\n\t\t\t});\n\t\t</script>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "</aside></div><script>\n\t\t\tfunction toggleDropdown() {\n\t\t\t\tdocument.getElementById('watchlist-dropdown').classList.toggle('open');\n\t\t\t}\n\t\t\tdocument.addEventListener('click', function(e) {\n\t\t\t\tconst dropdown = document.getElementById('watchlist-dropdown');\n\t\t\t\tif (dropdown && !dropdown.contains(e.target)) {\n\t\t\t\t\tdropdown.classList.remove('open');\n\t\t\t\t}\n\t\t\t});\n\t\t</script>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -644,36 +631,36 @@ func WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string,
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var30 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var30 == nil {
|
||||
templ_7745c5c3_Var30 = templ.NopComponent
|
||||
templ_7745c5c3_Var29 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var29 == nil {
|
||||
templ_7745c5c3_Var29 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "<div class=\"dropdown\" id=\"watchlist-dropdown\"><button class=\"dropdown-trigger\" onclick=\"toggleDropdown()\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "<div class=\"dropdown\" id=\"watchlist-dropdown\"><button class=\"dropdown-trigger\" onclick=\"toggleDropdown()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentStatus != "" {
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(currentStatus))
|
||||
var templ_7745c5c3_Var30 string
|
||||
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(currentStatus))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 203, Col: 33}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 195, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "add to watchlist ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, "add to watchlist ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<span class=\"dropdown-arrow\">▾</span></button><div class=\"dropdown-menu\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "<span class=\"dropdown-arrow\">▾</span></button><div class=\"dropdown-menu\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -698,25 +685,25 @@ func WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string,
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentStatus != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "<div class=\"dropdown-divider\"></div><button class=\"dropdown-item remove\" hx-delete=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "<div class=\"dropdown-divider\"></div><button class=\"dropdown-item remove\" hx-delete=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var32 string
|
||||
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))))
|
||||
var templ_7745c5c3_Var31 string
|
||||
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 219, Col: 77}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 211, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">remove from list</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">remove from list</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "</div></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -740,66 +727,66 @@ func dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish stri
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var33 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var33 == nil {
|
||||
templ_7745c5c3_Var33 = templ.NopComponent
|
||||
templ_7745c5c3_Var32 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var32 == nil {
|
||||
templ_7745c5c3_Var32 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var34 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var34...)
|
||||
var templ_7745c5c3_Var33 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var33...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "<button class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "<button class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var34 string
|
||||
templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var33).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var34))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var35 string
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var34).String())
|
||||
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 224, Col: 266}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var36 string
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing))
|
||||
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 232, Col: 266}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 228, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var37 string
|
||||
templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 236, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var37))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if status == currentStatus {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "<span class=\"check\">✓</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "<span class=\"check\">✓</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -823,66 +810,66 @@ func statusOption(anime jikan.Anime, status string, currentStatus string) templ.
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var38 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var38 == nil {
|
||||
templ_7745c5c3_Var38 = templ.NopComponent
|
||||
templ_7745c5c3_Var37 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var37 == nil {
|
||||
templ_7745c5c3_Var37 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
var templ_7745c5c3_Var39 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var39...)
|
||||
var templ_7745c5c3_Var38 = []any{"dropdown-item", templ.KV("active", status == currentStatus)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "<button class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "<button class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var39 string
|
||||
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var38).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var40 string
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var39).String())
|
||||
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 1, Col: 0}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 239, Col: 255}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" hx-post=\"/api/watchlist\" hx-vals=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var41 string
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s"}`, anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), status))
|
||||
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 247, Col: 255}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 243, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "\" hx-target=\"#watchlist-dropdown\" hx-swap=\"outerHTML\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var42 string
|
||||
templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.JoinStringErrs(formatStatus(status))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 251, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var42))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, " ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, " ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if status == currentStatus {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "<span class=\"check\">✓</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<span class=\"check\">✓</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</button>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -923,151 +910,151 @@ func AnimeRelationsList(relations []jikan.RelationEntry) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var43 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var43 == nil {
|
||||
templ_7745c5c3_Var43 = templ.NopComponent
|
||||
templ_7745c5c3_Var42 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var42 == nil {
|
||||
templ_7745c5c3_Var42 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
if len(relations) > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<h3>related</h3><div class=\"relations-grid\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<h3>related</h3><div class=\"relations-grid\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, rel := range relations {
|
||||
if rel.IsCurrent {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<div class=\"relation-card current\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<div class=\"relation-card current\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if rel.Anime.ImageURL() != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var43 string
|
||||
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 275, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var44 string
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
|
||||
templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 283, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 275, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var44))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 283, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "\" class=\"relation-thumb\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "\" class=\"relation-thumb\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div class=\"no-image\">no image</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"no-image\">no image</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<div class=\"relation-title\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "<div class=\"relation-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 string
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
var templ_7745c5c3_Var45 string
|
||||
templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 287, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 279, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var46 templ.SafeURL
|
||||
templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", rel.Anime.MalID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 282, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</div></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var47 templ.SafeURL
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinURLErrs(templ.URL(fmt.Sprintf("/anime/%d", rel.Anime.MalID)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 290, Col: 67}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "\" class=\"relation-card\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "\" class=\"relation-card\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if rel.Anime.ImageURL() != "" {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var47 string
|
||||
templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 284, Col: 38}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var48 string
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.ImageURL())
|
||||
templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 292, Col: 38}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 284, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 292, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "\" class=\"relation-thumb\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "\" class=\"relation-thumb\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div class=\"no-image\">no image</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<div class=\"no-image\">no image</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "<div class=\"relation-title\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "<div class=\"relation-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var50 string
|
||||
templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
var templ_7745c5c3_Var49 string
|
||||
templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(rel.Anime.DisplayTitle())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 296, Col: 60}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/anime.templ`, Line: 288, Col: 60}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</div></a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</div></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "fmt"
|
||||
|
||||
// EpisodesList renders a list of episodes for an anime
|
||||
templ EpisodesList(animeID int, animeTitle string, episodes []jikan.Episode, totalEpisodes int) {
|
||||
<section class="episodes-section">
|
||||
<div class="episodes-header">
|
||||
<h3>episodes</h3>
|
||||
if totalEpisodes > 0 {
|
||||
<span class="episode-count">{ fmt.Sprintf("%d episodes", totalEpisodes) }</span>
|
||||
}
|
||||
</div>
|
||||
if len(episodes) == 0 {
|
||||
<p class="no-episodes">no episode data available</p>
|
||||
} else {
|
||||
<div class="episodes-grid">
|
||||
for _, ep := range episodes {
|
||||
@EpisodeCard(animeID, animeTitle, ep)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
// EpisodeCard renders a single episode card with play button
|
||||
templ EpisodeCard(animeID int, animeTitle string, ep jikan.Episode) {
|
||||
<div class="episode-card" data-episode={ fmt.Sprintf("%d", ep.MalID) }>
|
||||
<div class="episode-number">{ fmt.Sprintf("%d", ep.MalID) }</div>
|
||||
<div class="episode-info">
|
||||
<div class="episode-title">
|
||||
if ep.Title != "" {
|
||||
{ ep.Title }
|
||||
} else {
|
||||
{ fmt.Sprintf("Episode %d", ep.MalID) }
|
||||
}
|
||||
</div>
|
||||
if ep.Filler {
|
||||
<span class="episode-tag filler">filler</span>
|
||||
}
|
||||
if ep.Recap {
|
||||
<span class="episode-tag recap">recap</span>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="episode-play"
|
||||
hx-get={ string(templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, ep.MalID))) }
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="play-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
// EpisodesLoading renders a loading state for episodes
|
||||
templ EpisodesLoading() {
|
||||
<section class="episodes-section">
|
||||
<div class="episodes-header">
|
||||
<h3>episodes</h3>
|
||||
</div>
|
||||
<div class="loading-indicator">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<span>loading episodes</span>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
// Code generated by templ - DO NOT EDIT.
|
||||
|
||||
// templ: version: v0.3.1001
|
||||
package templates
|
||||
|
||||
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||
|
||||
import "github.com/a-h/templ"
|
||||
import templruntime "github.com/a-h/templ/runtime"
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "fmt"
|
||||
|
||||
// EpisodesList renders a list of episodes for an anime
|
||||
func EpisodesList(animeID int, animeTitle string, episodes []jikan.Episode, totalEpisodes int) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var1 == nil {
|
||||
templ_7745c5c3_Var1 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<section class=\"episodes-section\"><div class=\"episodes-header\"><h3>episodes</h3>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if totalEpisodes > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<span class=\"episode-count\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var2 string
|
||||
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d episodes", totalEpisodes))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 12, Col: 75}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(episodes) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<p class=\"no-episodes\">no episode data available</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<div class=\"episodes-grid\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
templ_7745c5c3_Err = EpisodeCard(animeID, animeTitle, ep).Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// EpisodeCard renders a single episode card with play button
|
||||
func EpisodeCard(animeID int, animeTitle string, ep jikan.Episode) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var3 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var3 == nil {
|
||||
templ_7745c5c3_Var3 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<div class=\"episode-card\" data-episode=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ep.MalID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 29, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\"><div class=\"episode-number\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", ep.MalID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 30, Col: 59}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "</div><div class=\"episode-info\"><div class=\"episode-title\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if ep.Title != "" {
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(ep.Title)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 34, Col: 15}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("Episode %d", ep.MalID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 36, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if ep.Filler {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span class=\"episode-tag filler\">filler</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if ep.Recap {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"episode-tag recap\">recap</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div><button class=\"episode-play\" hx-get=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(string(templ.URL(fmt.Sprintf("/watch/%d/%d", animeID, ep.MalID))))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/templates/episodes.templ`, Line: 48, Col: 77}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\" hx-target=\"body\" hx-push-url=\"true\"><svg viewBox=\"0 0 24 24\" fill=\"currentColor\" class=\"play-icon\"><path d=\"M8 5v14l11-7z\"></path></svg></button></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// EpisodesLoading renders a loading state for episodes
|
||||
func EpisodesLoading() templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
return templ_7745c5c3_CtxErr
|
||||
}
|
||||
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||
if !templ_7745c5c3_IsBuffer {
|
||||
defer func() {
|
||||
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err == nil {
|
||||
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||
}
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var9 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var9 == nil {
|
||||
templ_7745c5c3_Var9 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<section class=\"episodes-section\"><div class=\"episodes-header\"><h3>episodes</h3></div><div class=\"loading-indicator\"><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><div class=\"loading-dot\"></div><span>loading episodes</span></div></section>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -1,337 +0,0 @@
|
||||
package templates
|
||||
|
||||
import "mal/internal/jikan"
|
||||
import "mal/internal/nyaa"
|
||||
import "fmt"
|
||||
|
||||
// WatchPage renders the video player page
|
||||
templ WatchPage(anime jikan.Anime, episode int, torrents []nyaa.Torrent) {
|
||||
@Layout(fmt.Sprintf("Watch %s - Episode %d", anime.DisplayTitle(), episode)) {
|
||||
<div class="watch-page">
|
||||
<div class="watch-header">
|
||||
<a href={ templ.URL(fmt.Sprintf("/anime/%d", anime.MalID)) } class="back-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="back-icon">
|
||||
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/>
|
||||
</svg>
|
||||
back to { anime.DisplayTitle() }
|
||||
</a>
|
||||
<h1>Episode { fmt.Sprintf("%d", episode) }</h1>
|
||||
</div>
|
||||
|
||||
<div class="watch-content">
|
||||
<div class="player-container" id="player-container">
|
||||
<div class="player-placeholder" id="player-placeholder">
|
||||
<div class="player-message">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="player-icon">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<p>select a source to start streaming</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="player-loading" class="player-loading hidden">
|
||||
<div class="loading-stream">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
</div>
|
||||
<p id="loading-status">connecting to peers...</p>
|
||||
<p id="loading-progress" class="loading-progress"></p>
|
||||
</div>
|
||||
<video
|
||||
id="video-player"
|
||||
class="video-player hidden"
|
||||
controls
|
||||
playsinline
|
||||
>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
<div class="sources-panel">
|
||||
<h3>available sources</h3>
|
||||
if len(torrents) == 0 {
|
||||
<div class="no-sources">
|
||||
<p>no torrents found for this episode</p>
|
||||
<p class="hint">try searching manually</p>
|
||||
</div>
|
||||
} else {
|
||||
<div class="sources-list">
|
||||
for _, t := range torrents {
|
||||
if t.Magnet != "" {
|
||||
@TorrentSource(t)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="manual-search">
|
||||
<h4>manual search</h4>
|
||||
<form
|
||||
hx-get="/api/stream/search-htmx"
|
||||
hx-target="#search-results"
|
||||
hx-indicator="#search-loading"
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
name="q"
|
||||
placeholder={ fmt.Sprintf("%s %02d", anime.Title, episode) }
|
||||
value={ fmt.Sprintf("%s %02d", anime.Title, episode) }
|
||||
class="search-input"
|
||||
/>
|
||||
<button type="submit" class="search-btn">search</button>
|
||||
</form>
|
||||
<div id="search-loading" class="htmx-indicator">searching...</div>
|
||||
<div id="search-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="episode-nav">
|
||||
if episode > 1 {
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode-1)) }
|
||||
class="nav-btn prev"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/>
|
||||
</svg>
|
||||
previous
|
||||
</a>
|
||||
} else {
|
||||
<span class="nav-btn disabled">previous</span>
|
||||
}
|
||||
if anime.Episodes == 0 || episode < anime.Episodes {
|
||||
<a
|
||||
href={ templ.URL(fmt.Sprintf("/watch/%d/%d", anime.MalID, episode+1)) }
|
||||
class="nav-btn next"
|
||||
>
|
||||
next
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/>
|
||||
</svg>
|
||||
</a>
|
||||
} else {
|
||||
<span class="nav-btn disabled">next</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HLS.js for browser playback -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
|
||||
<script>
|
||||
const currentEpisode = { fmt.Sprintf("%d", episode) };
|
||||
let currentStreamHash = null;
|
||||
let progressInterval = null;
|
||||
let hls = null;
|
||||
|
||||
function startStream(magnet) {
|
||||
if (!magnet) {
|
||||
showError('no magnet link available for this torrent');
|
||||
return;
|
||||
}
|
||||
|
||||
showLoading('connecting to peers...');
|
||||
|
||||
fetch('/api/stream/start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ magnet: magnet })
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
return res.text().then(text => { throw new Error(text); });
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.info_hash) {
|
||||
currentStreamHash = data.info_hash;
|
||||
showLoading('starting transcoding...');
|
||||
startProgressUpdates();
|
||||
startHLS();
|
||||
} else {
|
||||
showError('invalid response from server');
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
showError(err.message || 'failed to start stream');
|
||||
});
|
||||
}
|
||||
|
||||
function startHLS() {
|
||||
showLoading('preparing video stream...');
|
||||
|
||||
fetch('/api/stream/hls/' + currentStreamHash + '?ep=' + currentEpisode, {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
return res.text().then(text => { throw new Error(text); });
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(data => {
|
||||
playHLS(data.playlist);
|
||||
})
|
||||
.catch(err => {
|
||||
showError('transcoding failed: ' + (err.message || 'unknown error'));
|
||||
});
|
||||
}
|
||||
|
||||
function playHLS(playlistUrl) {
|
||||
const video = document.getElementById('video-player');
|
||||
|
||||
// Cleanup previous instance
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
|
||||
document.getElementById('player-placeholder').classList.add('hidden');
|
||||
document.getElementById('player-loading').classList.add('hidden');
|
||||
video.classList.remove('hidden');
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({
|
||||
enableWorker: true,
|
||||
lowLatencyMode: false,
|
||||
backBufferLength: 90
|
||||
});
|
||||
|
||||
hls.loadSource(playlistUrl);
|
||||
hls.attachMedia(video);
|
||||
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
||||
video.play().catch(e => console.log('Autoplay blocked:', e));
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.ERROR, function(event, data) {
|
||||
if (data.fatal) {
|
||||
switch(data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
console.error('Network error, trying to recover...');
|
||||
hls.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
console.error('Media error, trying to recover...');
|
||||
hls.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
showError('Playback error: ' + data.details);
|
||||
hls.destroy();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
// Native HLS support (Safari)
|
||||
video.src = playlistUrl;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.play().catch(e => console.log('Autoplay blocked:', e));
|
||||
});
|
||||
} else {
|
||||
showError('HLS playback not supported in this browser');
|
||||
}
|
||||
}
|
||||
|
||||
function showLoading(status) {
|
||||
document.getElementById('player-placeholder').classList.add('hidden');
|
||||
document.getElementById('video-player').classList.add('hidden');
|
||||
document.getElementById('player-loading').classList.remove('hidden');
|
||||
document.getElementById('loading-status').textContent = status;
|
||||
document.getElementById('loading-progress').textContent = '';
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
stopProgressUpdates();
|
||||
document.getElementById('player-loading').classList.add('hidden');
|
||||
document.getElementById('video-player').classList.add('hidden');
|
||||
|
||||
const placeholder = document.getElementById('player-placeholder');
|
||||
placeholder.classList.remove('hidden');
|
||||
placeholder.innerHTML = '<div class="player-error">' + escapeHtml(message) + '</div>';
|
||||
}
|
||||
|
||||
function startProgressUpdates() {
|
||||
if (progressInterval) clearInterval(progressInterval);
|
||||
progressInterval = setInterval(updateProgress, 2000);
|
||||
updateProgress();
|
||||
}
|
||||
|
||||
function stopProgressUpdates() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
function updateProgress() {
|
||||
if (!currentStreamHash) return;
|
||||
|
||||
fetch('/api/stream/info/' + currentStreamHash)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
const progress = data.progress.toFixed(1);
|
||||
const peers = data.peers;
|
||||
const progressEl = document.getElementById('loading-progress');
|
||||
if (progressEl) {
|
||||
progressEl.textContent = progress + '% downloaded | ' + peers + ' peers';
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
function formatSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// Handle torrent selection from search results and source list
|
||||
document.addEventListener('click', function(e) {
|
||||
const item = e.target.closest('[data-magnet]');
|
||||
if (item && item.dataset.magnet) {
|
||||
e.preventDefault();
|
||||
|
||||
// Mark selected
|
||||
document.querySelectorAll('[data-magnet]').forEach(el => el.classList.remove('selected'));
|
||||
item.classList.add('selected');
|
||||
|
||||
startStream(item.dataset.magnet);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on page leave
|
||||
window.addEventListener('beforeunload', function() {
|
||||
stopProgressUpdates();
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
// TorrentSource renders a single torrent source option
|
||||
templ TorrentSource(t nyaa.Torrent) {
|
||||
<div class="source-item" data-magnet={ t.Magnet }>
|
||||
<div class="source-title">{ truncateTitle(t.Title, 60) }</div>
|
||||
<div class="source-meta">
|
||||
<span class="source-size">{ t.Size }</span>
|
||||
<span class="source-seeds">{ fmt.Sprintf("%d", t.Seeders) } seeds</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
func truncateTitle(s string, max int) string {
|
||||
if len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max-3] + "..."
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user