update torrent search, onlinestream search

This commit is contained in:
5rahim
2025-04-17 09:37:15 +00:00
parent 66109b198e
commit 89909bde8f
20 changed files with 767 additions and 64 deletions

View File

@@ -41002,8 +41002,8 @@
"name": "ParsedData",
"jsonName": "parsedData",
"goType": "habari.Metadata",
"typescriptType": "Metadata",
"usedTypescriptType": "Metadata",
"typescriptType": "Habari_Metadata",
"usedTypescriptType": "Habari_Metadata",
"usedStructName": "habari.Metadata",
"required": false,
"public": true,
@@ -66224,6 +66224,36 @@
],
"comments": []
},
{
"filepath": "../internal/torrents/torrent/search.go",
"filename": "search.go",
"name": "TorrentMetadata",
"formattedName": "Torrent_TorrentMetadata",
"package": "torrent",
"fields": [
{
"name": "Distance",
"jsonName": "distance",
"goType": "int",
"typescriptType": "number",
"required": true,
"public": true,
"comments": []
},
{
"name": "Metadata",
"jsonName": "metadata",
"goType": "habari.Metadata",
"typescriptType": "Habari_Metadata",
"usedTypescriptType": "Habari_Metadata",
"usedStructName": "habari.Metadata",
"required": false,
"public": true,
"comments": []
}
],
"comments": []
},
{
"filepath": "../internal/torrents/torrent/search.go",
"filename": "search.go",
@@ -66257,6 +66287,19 @@
" TorrentPreview for each torrent"
]
},
{
"name": "TorrentMetadata",
"jsonName": "torrentMetadata",
"goType": "map[string]TorrentMetadata",
"typescriptType": "Record\u003cstring, Torrent_TorrentMetadata\u003e",
"usedTypescriptType": "Torrent_TorrentMetadata",
"usedStructName": "torrent.TorrentMetadata",
"required": false,
"public": true,
"comments": [
" Torrent metadata"
]
},
{
"name": "DebridInstantAvailability",
"jsonName": "debridInstantAvailability",
@@ -66269,6 +66312,19 @@
"comments": [
" Debrid instant availability"
]
},
{
"name": "AnimeMetadata",
"jsonName": "animeMetadata",
"goType": "metadata.AnimeMetadata",
"typescriptType": "Metadata_AnimeMetadata",
"usedTypescriptType": "Metadata_AnimeMetadata",
"usedStructName": "metadata.AnimeMetadata",
"required": false,
"public": true,
"comments": [
" AniZip media"
]
}
],
"comments": []
@@ -69592,5 +69648,231 @@
}
],
"comments": []
},
{
"filepath": "../internal/vendor_habari/vendor_habari.go",
"filename": "vendor_habari.go",
"name": "Metadata",
"formattedName": "Habari_Metadata",
"package": "vendor_habari",
"fields": [
{
"name": "SeasonNumber",
"jsonName": "season_number",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "PartNumber",
"jsonName": "part_number",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "Title",
"jsonName": "title",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "FormattedTitle",
"jsonName": "formatted_title",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "AnimeType",
"jsonName": "anime_type",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "Year",
"jsonName": "year",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "AudioTerm",
"jsonName": "audio_term",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "DeviceCompatibility",
"jsonName": "device_compatibility",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "EpisodeNumber",
"jsonName": "episode_number",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "OtherEpisodeNumber",
"jsonName": "other_episode_number",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "EpisodeNumberAlt",
"jsonName": "episode_number_alt",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "EpisodeTitle",
"jsonName": "episode_title",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "FileChecksum",
"jsonName": "file_checksum",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "FileExtension",
"jsonName": "file_extension",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "FileName",
"jsonName": "file_name",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "Language",
"jsonName": "language",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "ReleaseGroup",
"jsonName": "release_group",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "ReleaseInformation",
"jsonName": "release_information",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "ReleaseVersion",
"jsonName": "release_version",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "Source",
"jsonName": "source",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "Subtitles",
"jsonName": "subtitles",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "VideoResolution",
"jsonName": "video_resolution",
"goType": "string",
"typescriptType": "string",
"required": false,
"public": true,
"comments": []
},
{
"name": "VideoTerm",
"jsonName": "video_term",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
},
{
"name": "VolumeNumber",
"jsonName": "volume_number",
"goType": "[]string",
"typescriptType": "Array\u003cstring\u003e",
"required": false,
"public": true,
"comments": []
}
],
"comments": []
}
]

View File

@@ -111,6 +111,8 @@ var typePrefixesByPackage = map[string]string{
"debrid": "Debrid_",
"debrid_client": "DebridClient_",
"report": "Report_",
"habari": "Habari_",
"vendor_habari": "Habari_",
}
func getTypePrefix(packageName string) string {

View File

@@ -22,6 +22,7 @@ var additionalStructNames = []string{
"torrentstream.TorrentStatus",
"debrid_client.StreamState",
"extension_repo.TrayPluginExtensionItem",
"vendor_habari.Metadata",
}
// GenerateTypescriptFile generates a Typescript file containing the types for the API routes parameters and responses based on the Docs struct.

2
go.mod
View File

@@ -4,7 +4,7 @@ go 1.24.1
require (
fyne.io/systray v1.11.0
github.com/5rahim/habari v0.1.4
github.com/5rahim/habari v0.1.5
github.com/Masterminds/semver/v3 v3.3.1
github.com/Microsoft/go-winio v0.6.2
github.com/PuerkitoBio/goquery v1.10.0

4
go.sum
View File

@@ -6,8 +6,8 @@ filippo.io/edwards25519 v1.0.0-rc.1 h1:m0VOOB23frXZvAOK44usCgLWvtsxIoMCTBGJZlpmG
filippo.io/edwards25519 v1.0.0-rc.1/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/5rahim/habari v0.1.4 h1:KVSK6wuAaSX9UN9dcmfyjIHwrBsAk5lShumRNH8RHHs=
github.com/5rahim/habari v0.1.4/go.mod h1:0nBj4/5OxTAoIICP4P3+/YJGNf8L7w+gnU1ivj7nFJA=
github.com/5rahim/habari v0.1.5 h1:cYqPtHooXV5yNafogZTWf6OWn+sh0CkB5V5IVV/Q7Ww=
github.com/5rahim/habari v0.1.5/go.mod h1:0nBj4/5OxTAoIICP4P3+/YJGNf8L7w+gnU1ivj7nFJA=
github.com/99designs/gqlgen v0.17.54 h1:AsF49k/7RJlwA00RQYsYN0T8cQuaosnV/7G1dHC3Uh8=
github.com/99designs/gqlgen v0.17.54/go.mod h1:77/+pVe6zlTsz++oUg2m8VLgzdUPHxjoAG3BxI5y8Rc=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=

View File

@@ -12,7 +12,14 @@ func (m *BaseAnime) GetTitleSafe() string {
if m.GetTitle().GetRomaji() != nil {
return *m.GetTitle().GetRomaji()
}
return "N/A"
return ""
}
func (m *BaseAnime) GetEnglishTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return ""
}
func (m *BaseAnime) GetRomajiTitleSafe() string {
@@ -22,7 +29,7 @@ func (m *BaseAnime) GetRomajiTitleSafe() string {
if m.GetTitle().GetEnglish() != nil {
return *m.GetTitle().GetEnglish()
}
return "N/A"
return ""
}
func (m *BaseAnime) GetPreferredTitle() string {

View File

@@ -13,6 +13,9 @@ type (
}
SearchOptions struct {
// The media object provided by Seanime.
Media Media `json:"media"`
// The search query.
Query string `json:"query"`
// Whether to search for subbed or dubbed anime.
Dub bool `json:"dub"`
@@ -21,6 +24,40 @@ type (
Year int `json:"year"`
}
Media struct {
// AniList ID of the media.
ID int `json:"id"`
// MyAnimeList ID of the media.
IDMal *int `json:"idMal,omitempty"`
// e.g. "FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"
// This will be set to "NOT_YET_RELEASED" if the status is unknown.
Status string `json:"status,omitempty"`
// e.g. "TV", "TV_SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC"
// This will be set to "TV" if the format is unknown.
Format string `json:"format,omitempty"`
// e.g. "Attack on Titan"
// This will be undefined if the english title is unknown.
EnglishTitle *string `json:"englishTitle,omitempty"`
// e.g. "Shingeki no Kyojin"
RomajiTitle string `json:"romajiTitle,omitempty"`
// TotalEpisodes is total number of episodes of the media.
// This will be -1 if the total number of episodes is unknown / not applicable.
EpisodeCount int `json:"episodeCount,omitempty"`
// All alternative titles of the media.
Synonyms []string `json:"synonyms"`
// Whether the media is NSFW.
IsAdult bool `json:"isAdult"`
// Start date of the media.
// This will be undefined if it has no start date.
StartDate *FuzzyDate `json:"startDate,omitempty"`
}
FuzzyDate struct {
Year int `json:"year"`
Month *int `json:"month"`
Day *int `json:"day"`
}
Settings struct {
EpisodeServers []string `json:"episodeServers"`
SupportsDub bool `json:"supportsDub"`

View File

@@ -36,7 +36,28 @@ declare type VideoSubtitle = {
isDefault: boolean
}
declare interface Media {
id: number
idMal?: number
status?: string
format?: string
englishTitle?: string
romajiTitle?: string
episodeCount?: number
absoluteSeasonOffset?: number
synonyms: string[]
isAdult: boolean
startDate?: FuzzyDate
}
declare interface FuzzyDate {
year: number
month?: number
day?: number
}
declare type SearchOptions = {
media: Media
query: string
dub: boolean
year?: number

View File

@@ -2721,7 +2721,7 @@ declare namespace $app {
* - Filepath: internal/library/autodownloader/autodownloader_torrent.go
*/
interface AutoDownloader_NormalizedTorrent {
parsedData?: $habari.Metadata;
parsedData?: Habari_Metadata;
/**
* Access using GetMagnet()
*/

View File

@@ -162,7 +162,7 @@ func (r *Repository) GetMediaEpisodes(provider string, media *anilist.BaseAnime,
// Fetch the episode list from the provider
// "from" and "to" are set to 0 in order not to fetch episode servers
ec, err := r.getEpisodeContainer(provider, mId, media.GetAllTitles(), 0, 0, dubbed, media.GetStartYearSafe())
ec, err := r.getEpisodeContainer(provider, media, 0, 0, dubbed, media.GetStartYearSafe())
if err != nil {
return nil, err
}
@@ -240,7 +240,7 @@ func (r *Repository) GetEpisodeSources(provider string, mId int, number int, dub
// | Episode servers |
// +---------------------+
ec, err := r.getEpisodeContainer(provider, mId, media.GetAllTitles(), number, number, dubbed, year)
ec, err := r.getEpisodeContainer(provider, media, number, number, dubbed, year)
if err != nil {
return nil, err
}

View File

@@ -3,9 +3,10 @@ package onlinestream
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/extension"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/onlinestream/providers"
onlinestream_providers "seanime/internal/onlinestream/providers"
"seanime/internal/util/comparison"
"strings"
)
@@ -42,11 +43,11 @@ type (
// - This function can be used to only get the episode details by setting 'from' and 'to' to 0.
//
// Since the episode details are cached, we can request episode servers multiple times without fetching the episode details again.
func (r *Repository) getEpisodeContainer(provider string, mId int, titles []*string, from int, to int, dubbed bool, year int) (*episodeContainer, error) {
func (r *Repository) getEpisodeContainer(provider string, media *anilist.BaseAnime, from int, to int, dubbed bool, year int) (*episodeContainer, error) {
r.logger.Debug().
Str("provider", provider).
Int("mediaId", mId).
Int("mediaId", media.ID).
Int("from", from).
Int("to", to).
Bool("dubbed", dubbed).
@@ -55,7 +56,7 @@ func (r *Repository) getEpisodeContainer(provider string, mId int, titles []*str
// Key identifying the provider episode list in the file cache.
// It includes "dubbed" because Gogoanime has a different entry for dubbed anime.
// e.g. 1$provider$true
providerEpisodeListKey := fmt.Sprintf("%d$%s$%v", mId, provider, dubbed)
providerEpisodeListKey := fmt.Sprintf("%d$%s$%v", media.ID, provider, dubbed)
// Create the episode container
ec := &episodeContainer{
@@ -70,14 +71,14 @@ func (r *Repository) getEpisodeContainer(provider string, mId int, titles []*str
Msgf("onlinestream: Fetching %s episode list", provider)
// Buckets for caching the episode list and episode data.
fcEpisodeListBucket := r.getFcEpisodeListBucket(provider, mId)
fcEpisodeDataBucket := r.getFcEpisodeDataBucket(provider, mId)
fcEpisodeListBucket := r.getFcEpisodeListBucket(provider, media.ID)
fcEpisodeDataBucket := r.getFcEpisodeDataBucket(provider, media.ID)
// Check if the episode list is cached to avoid fetching it again.
var providerEpisodeList []*hibikeonlinestream.EpisodeDetails
if found, _ := r.fileCacher.Get(fcEpisodeListBucket, providerEpisodeListKey, &providerEpisodeList); !found {
var err error
providerEpisodeList, err = r.getProviderEpisodeListFromTitles(provider, mId, titles, dubbed, year)
providerEpisodeList, err = r.getProviderEpisodeListFromTitles(provider, media, dubbed, year)
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to get provider episodes")
return nil, err // ErrNoAnimeFound or ErrNoEpisodes
@@ -96,7 +97,7 @@ func (r *Repository) getEpisodeContainer(provider string, mId int, titles []*str
if episodeDetails.Number >= from && episodeDetails.Number <= to {
// Check if the episode is cached to avoid fetching the sources again.
key := fmt.Sprintf("%d$%s$%d$%v", mId, provider, episodeDetails.Number, dubbed)
key := fmt.Sprintf("%d$%s$%d$%v", media.ID, provider, episodeDetails.Number, dubbed)
r.logger.Debug().
Str("key", key).
@@ -195,19 +196,21 @@ func (r *Repository) getProviderEpisodeServers(provider string, episodeDetails *
// getProviderEpisodeListFromTitles gets all the hibikeonlinestream.EpisodeDetails from the provider based on the anime's titles.
// It returns ErrNoAnimeFound if the anime is not found or ErrNoEpisodes if no episodes are found.
func (r *Repository) getProviderEpisodeListFromTitles(provider string, mId int, titles []*string, dubbed bool, year int) ([]*hibikeonlinestream.EpisodeDetails, error) {
func (r *Repository) getProviderEpisodeListFromTitles(provider string, media *anilist.BaseAnime, dubbed bool, year int) ([]*hibikeonlinestream.EpisodeDetails, error) {
var ret []*hibikeonlinestream.EpisodeDetails
romajiTitle := strings.ReplaceAll(*titles[0], ":", "")
englishTitle := ""
if len(titles) > 1 {
englishTitle = strings.ReplaceAll(*titles[1], ":", "")
}
// romajiTitle := strings.ReplaceAll(media.GetEnglishTitleSafe(), ":", "")
// englishTitle := strings.ReplaceAll(media.GetRomajiTitleSafe(), ":", "")
romajiTitle := media.GetRomajiTitleSafe()
englishTitle := media.GetEnglishTitleSafe()
providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider)
if !ok {
return nil, fmt.Errorf("provider extension '%s' not found", provider)
}
mId := media.ID
var matchId string
// +---------------------+
@@ -229,31 +232,75 @@ func (r *Repository) getProviderEpisodeListFromTitles(provider string, mId int,
// Get search results.
var searchResults []*hibikeonlinestream.SearchResult
// Search by romaji title
res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Query: romajiTitle,
Dub: dubbed,
Year: year,
})
if err == nil {
searchResults = res
} else {
queryMedia := hibikeonlinestream.Media{
ID: media.ID,
IDMal: media.GetIDMal(),
Status: string(*media.GetStatus()),
Format: string(*media.GetFormat()),
EnglishTitle: media.GetTitle().GetEnglish(),
RomajiTitle: media.GetRomajiTitleSafe(),
EpisodeCount: media.GetTotalEpisodeCount(),
Synonyms: media.GetSynonymsContainingSeason(),
IsAdult: *media.GetIsAdult(),
StartDate: &hibikeonlinestream.FuzzyDate{
Year: *media.GetStartDate().GetYear(),
Month: media.GetStartDate().GetMonth(),
Day: media.GetStartDate().GetDay(),
},
}
added := make(map[string]struct{})
if romajiTitle != "" {
// Search by romaji title
res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Media: queryMedia,
Query: romajiTitle,
Dub: dubbed,
Year: year,
})
if err == nil && len(res) > 0 {
searchResults = append(searchResults, res...)
for _, r := range res {
added[r.ID] = struct{}{}
}
}
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to search for romaji title")
}
r.logger.Debug().
Int("romajiTitleResults", len(res)).
Msg("onlinestream: Found results for romaji title")
}
if englishTitle != "" {
// Search by english title
res, err = providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Media: queryMedia,
Query: englishTitle,
Dub: dubbed,
Year: year,
})
if err == nil {
searchResults = res
if err == nil && len(res) > 0 {
for _, r := range res {
if _, ok := added[r.ID]; !ok {
searchResults = append(searchResults, r)
}
}
}
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to search for english title")
}
r.logger.Debug().
Int("englishTitleResults", len(res)).
Msg("onlinestream: Found results for english title")
}
if len(searchResults) == 0 {
return nil, ErrNoAnimeFound
}
bestResult := GetBestSearchResult(searchResults, titles)
bestResult := GetBestSearchResult(searchResults, media.GetAllTitles())
matchId = bestResult.ID
}

View File

@@ -88,7 +88,7 @@ func TestOnlineStream_GetEpisodes(t *testing.T) {
}
media := mediaF.GetMedia()
ec, err := os.getEpisodeContainer(tt.provider, tt.mediaId, media.GetAllTitles(), tt.from, tt.to, tt.dubbed, 0)
ec, err := os.getEpisodeContainer(tt.provider, media, tt.from, tt.to, tt.dubbed, 0)
if err != nil {
t.Fatalf("couldn't find episodes, %s", err)
}

View File

@@ -11,6 +11,7 @@ import (
"seanime/internal/library/anime"
"seanime/internal/util"
"seanime/internal/util/comparison"
"seanime/internal/util/result"
"slices"
"strconv"
"sync"
@@ -28,6 +29,10 @@ const (
AnimeSearchTypeSimple AnimeSearchType = "simple"
)
var (
metadataCache = result.NewResultMap[string, *TorrentMetadata]()
)
type (
AnimeSearchType string
@@ -51,11 +56,18 @@ type (
Torrent *hibiketorrent.AnimeTorrent `json:"torrent"`
}
TorrentMetadata struct {
Distance int `json:"distance"`
Metadata *habari.Metadata `json:"metadata"`
}
// SearchData is the struct returned by NewSmartSearch
SearchData struct {
Torrents []*hibiketorrent.AnimeTorrent `json:"torrents"` // Torrents found
Previews []*Preview `json:"previews"` // TorrentPreview for each torrent
TorrentMetadata map[string]*TorrentMetadata `json:"torrentMetadata"` // Torrent metadata
DebridInstantAvailability map[string]debrid.TorrentItemInstantAvailability `json:"debridInstantAvailability"` // Debrid instant availability
AnimeMetadata *metadata.AnimeMetadata `json:"animeMetadata"` // AniZip media
}
)
@@ -193,7 +205,42 @@ func (r *Repository) SearchAnime(ctx context.Context, opts AnimeSearchOptions) (
return nil, err
}
// Add preview for smart search
//
// Torrent metadata
//
torrentMetadata := make(map[string]*TorrentMetadata)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
wg.Add(len(torrents))
for _, t := range torrents {
go func(t *hibiketorrent.AnimeTorrent) {
defer wg.Done()
metadata, found := metadataCache.Get(t.Name)
if !found {
m := habari.Parse(t.Name)
var distance *comparison.LevenshteinResult
distance, ok := comparison.FindBestMatchWithLevenshtein(&m.Title, opts.Media.GetAllTitles())
if !ok {
distance = &comparison.LevenshteinResult{
Distance: 1000,
}
}
metadata = &TorrentMetadata{
Distance: distance.Distance,
Metadata: m,
}
metadataCache.Set(t.Name, metadata)
}
mu.Lock()
torrentMetadata[t.InfoHash] = metadata
mu.Unlock()
}(t)
}
wg.Wait()
//
// Previews
//
previews := make([]*Preview, 0)
if opts.Type == AnimeSearchTypeSmart {
@@ -244,8 +291,13 @@ func (r *Repository) SearchAnime(ctx context.Context, opts AnimeSearchOptions) (
})
ret = &SearchData{
Torrents: torrents,
Previews: previews,
Torrents: torrents,
Previews: previews,
TorrentMetadata: torrentMetadata,
}
if animeMetadata.IsPresent() {
ret.AnimeMetadata = animeMetadata.MustGet()
}
// Store the data in the cache
@@ -272,7 +324,16 @@ type createAnimeTorrentPreviewOptions struct {
func (r *Repository) createAnimeTorrentPreview(opts createAnimeTorrentPreviewOptions) *Preview {
parsedData := habari.Parse(opts.torrent.Name)
var parsedData *habari.Metadata
metadata, found := metadataCache.Get(opts.torrent.Name)
if !found { // Should always be found
parsedData = habari.Parse(opts.torrent.Name)
metadataCache.Set(opts.torrent.Name, &TorrentMetadata{
Distance: 1000,
Metadata: parsedData,
})
}
parsedData = metadata.Metadata
isBatch := opts.torrent.IsBestRelease ||
opts.torrent.IsBatch ||
@@ -289,7 +350,6 @@ func (r *Repository) createAnimeTorrentPreview(opts createAnimeTorrentPreviewOpt
if opts.torrent.FormattedSize == "" {
opts.torrent.FormattedSize = humanize.Bytes(uint64(opts.torrent.Size))
}
if isBatch {

View File

@@ -0,0 +1,28 @@
package vendor_habari
type Metadata struct {
SeasonNumber []string `json:"season_number,omitempty"`
PartNumber []string `json:"part_number,omitempty"`
Title string `json:"title,omitempty"`
FormattedTitle string `json:"formatted_title,omitempty"`
AnimeType []string `json:"anime_type,omitempty"`
Year string `json:"year,omitempty"`
AudioTerm []string `json:"audio_term,omitempty"`
DeviceCompatibility []string `json:"device_compatibility,omitempty"`
EpisodeNumber []string `json:"episode_number,omitempty"`
OtherEpisodeNumber []string `json:"other_episode_number,omitempty"`
EpisodeNumberAlt []string `json:"episode_number_alt,omitempty"`
EpisodeTitle string `json:"episode_title,omitempty"`
FileChecksum string `json:"file_checksum,omitempty"`
FileExtension string `json:"file_extension,omitempty"`
FileName string `json:"file_name,omitempty"`
Language []string `json:"language,omitempty"`
ReleaseGroup string `json:"release_group,omitempty"`
ReleaseInformation []string `json:"release_information,omitempty"`
ReleaseVersion []string `json:"release_version,omitempty"`
Source []string `json:"source,omitempty"`
Subtitles []string `json:"subtitles,omitempty"`
VideoResolution string `json:"video_resolution,omitempty"`
VideoTerm []string `json:"video_term,omitempty"`
VolumeNumber []string `json:"volume_number,omitempty"`
}

View File

@@ -2772,6 +2772,64 @@ export type Mediastream_MediaContainer = {
*/
export type Mediastream_StreamType = "transcode" | "optimized" | "direct"
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Metadata
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* - Filepath: internal/api/metadata/types.go
* - Filename: types.go
* - Package: metadata
*/
export type Metadata_AnimeMappings = {
animeplanetId: string
kitsuId: number
malId: number
type: string
anilistId: number
anisearchId: number
anidbId: number
notifymoeId: string
livechartId: number
thetvdbId: number
imdbId: string
themoviedbId: string
}
/**
* - Filepath: internal/api/metadata/types.go
* - Filename: types.go
* - Package: metadata
*/
export type Metadata_AnimeMetadata = {
titles?: Record<string, string>
episodes?: Record<string, Metadata_EpisodeMetadata>
episodeCount: number
specialCount: number
mappings?: Metadata_AnimeMappings
}
/**
* - Filepath: internal/api/metadata/types.go
* - Filename: types.go
* - Package: metadata
*/
export type Metadata_EpisodeMetadata = {
anidbId: number
tvdbId: number
title: string
image: string
airDate: string
length: number
summary: string
overview: string
episodeNumber: number
episode: string
seasonNumber: number
absoluteEpisodeNumber: number
anidbEid: number
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Models
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -3407,10 +3465,28 @@ export type Torrent_SearchData = {
* TorrentPreview for each torrent
*/
previews?: Array<Torrent_Preview>
/**
* Torrent metadata
*/
torrentMetadata?: Record<string, Torrent_TorrentMetadata>
/**
* Debrid instant availability
*/
debridInstantAvailability?: Record<string, Debrid_TorrentItemInstantAvailability>
/**
* AniZip media
*/
animeMetadata?: Metadata_AnimeMetadata
}
/**
* - Filepath: internal/torrents/torrent/search.go
* - Filename: search.go
* - Package: torrent
*/
export type Torrent_TorrentMetadata = {
distance: number
metadata?: Habari_Metadata
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -3589,6 +3665,42 @@ export type Updater_Update = {
type: string
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// VendorHabari
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* - Filepath: internal/vendor_habari/vendor_habari.go
* - Filename: vendor_habari.go
* - Package: vendor_habari
*/
export type Habari_Metadata = {
season_number?: Array<string>
part_number?: Array<string>
title?: string
formatted_title?: string
anime_type?: Array<string>
year?: string
audio_term?: Array<string>
device_compatibility?: Array<string>
episode_number?: Array<string>
other_episode_number?: Array<string>
episode_number_alt?: Array<string>
episode_title?: string
file_checksum?: string
file_extension?: string
file_name?: string
language?: Array<string>
release_group?: string
release_information?: Array<string>
release_version?: Array<string>
source?: Array<string>
subtitles?: Array<string>
video_resolution?: string
video_term?: Array<string>
volume_number?: Array<string>
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Videofile
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@@ -1,7 +1,12 @@
import { Torrent_TorrentMetadata } from "@/api/generated/types"
import { Badge } from "@/components/ui/badge"
import { Tooltip } from "@/components/ui/tooltip"
import { startCase } from "lodash"
import React from "react"
import { LuGauge } from "react-icons/lu"
import { BiMicrophone } from "react-icons/bi"
import { LiaMicrophoneSolid } from "react-icons/lia"
import { LuAudioLines, LuAudioWaveform, LuGauge } from "react-icons/lu"
import { PiChat, PiChatCircleTextDuotone, PiChatsTeardropDuotone, PiChatTeardropDuotone } from "react-icons/pi"
export function TorrentResolutionBadge({ resolution }: { resolution?: string }) {
@@ -41,13 +46,67 @@ export function TorrentSeedersBadge({ seeders }: { seeders: number }) {
}
export function TorrentParsedMetadata({ metadata }: { metadata: Torrent_TorrentMetadata | undefined }) {
if (!metadata) return null
const hasDubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("dub"))
// const hasSubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("sub"))
const hasMultiSubs = metadata.metadata?.subtitles?.some(n => n.toLocaleLowerCase().includes("multi"))
return (
<div className="flex flex-row gap-1 flex-wrap justify-end w-full lg:absolute bottom-0 right-0">
{metadata.metadata?.video_term?.map(term => (
<Badge
key={term}
className="rounded-md border-transparent bg-[--subtle] !text-[--muted] px-1"
>
{term}
</Badge>
))}
{!!metadata.metadata?.language?.length ? [...new Set(metadata.metadata?.language)].map(term => (
<Badge
key={term}
className="rounded-md border-transparent bg-[--subtle] px-1"
>
<PiChatTeardropDuotone className="text-lg text-[--blue]" /> {term}
</Badge>
)) : null}
{metadata.metadata?.audio_term?.filter(term => term.toLowerCase().includes("dual") || term.toLowerCase().includes("multi")).map(term => (
<Badge
key={term}
className="rounded-md border-transparent bg-[--subtle] px-1"
>
{/* <LuAudioWaveform className="text-lg text-[--blue]" /> {term} */}
<PiChatsTeardropDuotone className="text-lg text-[--rose]" /> {startCase(term)}
</Badge>
))}
{hasDubs && (
<Badge
className="rounded-md border-transparent bg-indigo-300 px-1"
>
<LiaMicrophoneSolid className="text-lg text-[--red]" /> Dubbed
</Badge>
)}
{hasMultiSubs && (
<Badge
className="rounded-md border-transparent bg-indigo-300 px-1"
>
<PiChatCircleTextDuotone className="text-lg text-[--orange]" /> Multi Subs
</Badge>
)}
</div>
)
}
export function TorrentDebridInstantAvailabilityBadge() {
return (
<Tooltip
trigger={<Badge
data-torrent-item-debrid-instant-availability-badge
className="rounded-[--radius-md] bg-transparent dark:text-[--green]"
className="rounded-[--radius-md] bg-transparent border-transparent dark:text-[--white] animate-pulse"
intent="white"
leftIcon={<LuGauge className="text-lg" />}
>

View File

@@ -91,19 +91,21 @@ export const TorrentPreviewItem = memo((props: TorrentPreviewItemProps) => {
</div>}
<div className="absolute left-0 top-0 w-full h-full max-w-[180px]" data-torrent-preview-item-image-container>
{(confirmed ? !!image : !!fallbackImage) && <Image
{(image || fallbackImage) && <Image
data-torrent-preview-item-image
src={confirmed ? image! : fallbackImage!}
src={image || fallbackImage!}
alt="episode image"
fill
className={cn(
"object-cover object-center absolute w-full h-full group-hover/torrent-preview-item:blur-0 transition-opacity opacity-25 group-hover/torrent-preview-item:opacity-60 z-[0] select-none pointer-events-none",
(!image && fallbackImage) && "opacity-10 group-hover/torrent-preview-item:opacity-30",
isSelected && "opacity-50",
)}
/>}
<div
data-torrent-preview-item-image-bottom-gradient
className="transition-colors absolute w-full h-full bg-gradient-to-l from-[--background] hover:from-[var(--hover-from-background-color)] to-transparent z-[1] select-none pointer-events-none"
data-torrent-preview-item-image-end-gradient
className="transition-colors absolute w-full h-full -right-2 bg-gradient-to-l from-[--background] hover:from-[var(--hover-from-background-color)] to-transparent z-[1] select-none pointer-events-none"
></div>
</div>
@@ -144,13 +146,14 @@ export const TorrentPreviewItem = memo((props: TorrentPreviewItemProps) => {
{!(image || fallbackImage) && !isBatch && <BsFileEarmarkPlayFill className="text-7xl absolute opacity-10" />}
</div>
<div className="relative overflow-hidden space-y-1" data-torrent-preview-item-metadata>
<div className="relative overflow-hidden space-y-1 w-full" data-torrent-preview-item-metadata>
{isInvalid && <p className="flex gap-2 text-red-300 items-center"><AiFillWarning
className="text-lg text-red-500"
/> Unidentified</p>}
<p
className={cn(
"font-medium text-base transition line-clamp-2 tracking-wider",
"font-normal text-[.9rem] transition line-clamp-2 tracking-wide",
isBasic && "text-sm",
)}
data-torrent-preview-item-title
@@ -158,15 +161,15 @@ export const TorrentPreviewItem = memo((props: TorrentPreviewItemProps) => {
{!!subtitle && <p
className={cn(
"text-sm tracking-wide group-hover/torrent-preview-item:text-gray-200 line-clamp-2 break-all",
!(_title) ? "font-medium transition tracking-wider" : "text-[--muted]",
"text-[.85rem] tracking-wide group-hover/torrent-preview-item:text-gray-200 line-clamp-2 break-all",
!(_title) ? "font-normal transition tracking-wide" : "text-[--muted]",
)}
data-torrent-preview-item-subtitle
>
{subtitle}
</p>}
<div className="flex items-center gap-2" data-torrent-preview-item-subcontent>
<div className="flex flex-col gap-2" data-torrent-preview-item-subcontent>
{children && children}
</div>
</div>

View File

@@ -1,6 +1,7 @@
import { Anime_Entry, Debrid_TorrentItemInstantAvailability, HibikeTorrent_AnimeTorrent, Torrent_Preview } from "@/api/generated/types"
import { Anime_Entry, Debrid_TorrentItemInstantAvailability, HibikeTorrent_AnimeTorrent, Torrent_Preview, Torrent_TorrentMetadata } from "@/api/generated/types"
import {
TorrentDebridInstantAvailabilityBadge,
TorrentParsedMetadata,
TorrentResolutionBadge,
TorrentSeedersBadge,
} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges"
@@ -29,6 +30,7 @@ type TorrentPreviewList = {
selectedTorrents: HibikeTorrent_AnimeTorrent[]
onToggleTorrent: (t: HibikeTorrent_AnimeTorrent) => void
type: TorrentSelectionType
torrentMetadata: Record<string, Torrent_TorrentMetadata> | undefined
}
export const TorrentPreviewList = React.memo((
@@ -40,6 +42,7 @@ export const TorrentPreviewList = React.memo((
onToggleTorrent,
debridInstantAvailability,
type,
torrentMetadata,
}: TorrentPreviewList) => {
// Add sorting state
const [sortField, setSortField] = useState<SortField>("seeders")
@@ -133,7 +136,7 @@ export const TorrentPreviewList = React.memo((
isSelected={selectedTorrents.findIndex(n => n.link === item.torrent!.link) !== -1}
onClick={() => onToggleTorrent(item.torrent!)}
>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex flex-wrap gap-2 items-center">
{item.torrent.isBestRelease && (
<Badge
className="rounded-[--radius-md] text-[0.8rem] bg-pink-800 border-transparent border"
@@ -153,6 +156,7 @@ export const TorrentPreviewList = React.memo((
<BiCalendarAlt /> {formatDistanceToNowSafe(item.torrent.date)}
</p>
</div>
<TorrentParsedMetadata metadata={torrentMetadata?.[item.torrent.infoHash!]} />
</TorrentPreviewItem>
)
})}

View File

@@ -1,6 +1,7 @@
import { Anime_Entry, Debrid_TorrentItemInstantAvailability, HibikeTorrent_AnimeTorrent } from "@/api/generated/types"
import { Anime_Entry, Debrid_TorrentItemInstantAvailability, HibikeTorrent_AnimeTorrent, Metadata_AnimeMetadata, Torrent_TorrentMetadata } from "@/api/generated/types"
import {
TorrentDebridInstantAvailabilityBadge,
TorrentParsedMetadata,
TorrentResolutionBadge,
TorrentSeedersBadge,
} from "@/app/(main)/entry/_containers/torrent-search/_components/torrent-item-badges"
@@ -34,6 +35,8 @@ type TorrentTable = {
isFetching: boolean
onToggleTorrent: (t: HibikeTorrent_AnimeTorrent) => void
debridInstantAvailability: Record<string, Debrid_TorrentItemInstantAvailability>
animeMetadata: Metadata_AnimeMetadata | undefined
torrentMetadata: Record<string, Torrent_TorrentMetadata> | undefined
}
export const TorrentTable = memo((
@@ -49,6 +52,8 @@ export const TorrentTable = memo((
isLoading,
onToggleTorrent,
debridInstantAvailability,
animeMetadata,
torrentMetadata,
}: TorrentTable) => {
// Add sorting state
const [sortField, setSortField] = useState<SortField>("seeders")
@@ -124,25 +129,54 @@ export const TorrentTable = memo((
</div>
<ScrollAreaBox className="h-[calc(100dvh_-_25rem)]">
{sortedTorrents.map(torrent => {
let episodeNumber = torrent.episodeNumber || -1
let totalEpisodes = entry?.media?.episodes || (entry?.media?.nextAiringEpisode?.episode ? entry?.media?.nextAiringEpisode?.episode : 0)
if (episodeNumber > totalEpisodes) {
// normalize episode number
for (const epKey in animeMetadata?.episodes) {
const ep = animeMetadata?.episodes?.[epKey]
if (ep?.absoluteEpisodeNumber === episodeNumber) {
episodeNumber = ep.episodeNumber
}
}
}
let episodeImage: string | undefined
if (!!animeMetadata && (episodeNumber ?? -1) >= 0) {
const episode = animeMetadata.episodes?.[episodeNumber!.toString()]
if (episode) {
episodeImage = episode.image
}
}
let distance = 9999
if (!!torrentMetadata && !!torrent.infoHash) {
const metadata = torrentMetadata[torrent.infoHash!]
if (metadata) {
distance = metadata.distance
}
}
if (distance > 10) {
episodeImage = undefined
}
return (
<TorrentPreviewItem
isBasic
// isBasic
link={torrent.link}
key={torrent.link}
title={torrent.name}
releaseGroup={torrent.releaseGroup || ""}
subtitle={torrent.isBatch ? torrent.name : (torrent?.episodeNumber || -1) >= 0
? `Episode ${torrent?.episodeNumber ?? "N/A"}`
subtitle={torrent.isBatch ? torrent.name : (episodeNumber ?? -1) >= 0
? `Episode ${episodeNumber}`
: ""}
isBatch={torrent.isBatch ?? false}
isBestRelease={torrent.isBestRelease}
// image={item.episode?.episodeMetadata?.image || item.episode?.baseAnime?.coverImage?.large ||
// (torrent.confirmed ? (entry.media?.coverImage?.large || entry.media?.bannerImage) : null)}
// fallbackImage={entry.media?.coverImage?.large || entry.media?.bannerImage}
image={distance <= 10 ? episodeImage : undefined}
fallbackImage={(entry?.media?.coverImage?.large || entry?.media?.bannerImage)}
isSelected={selectedTorrents.findIndex(n => n.link === torrent!.link) !== -1}
onClick={() => onToggleTorrent(torrent!)}
// confirmed={distance === 0}
>
<div className="flex flex-wrap gap-3 items-center">
<div className="flex flex-wrap gap-2 items-center">
{torrent.isBestRelease && (
<Badge
className="rounded-[--radius-md] text-[0.8rem] bg-pink-800 border-transparent border"
@@ -162,6 +196,7 @@ export const TorrentTable = memo((
<BiCalendarAlt /> {formatDistanceToNowSafe(torrent.date)}
</p>
</div>
<TorrentParsedMetadata metadata={torrentMetadata?.[torrent.infoHash!]} />
</TorrentPreviewItem>
)
})}

View File

@@ -347,6 +347,8 @@ export function TorrentSearchContainer({ type, entry }: { type: TorrentSelection
onToggleTorrent={handleToggleTorrent}
debridInstantAvailability={debridInstantAvailability}
type={type}
torrentMetadata={data?.torrentMetadata}
// animeMetadata={data?.animeMetadata}
/>
</>
)}
@@ -357,6 +359,7 @@ export function TorrentSearchContainer({ type, entry }: { type: TorrentSelection
{((searchType !== Torrent_SearchType.SMART) && !hasOneWarning && !previews?.length) && (
<>
<TorrentTable
entry={entry}
torrents={torrents}
globalFilter={globalFilter}
setGlobalFilter={setGlobalFilter}
@@ -367,6 +370,8 @@ export function TorrentSearchContainer({ type, entry }: { type: TorrentSelection
selectedTorrents={selectedTorrents}
onToggleTorrent={handleToggleTorrent}
debridInstantAvailability={debridInstantAvailability}
animeMetadata={data?.animeMetadata}
torrentMetadata={data?.torrentMetadata}
/>
</>
)}