Git Repository

Mini Video Library

Mini Video Library scannt lokale Medienordner, verwaltet Metadaten und Vorschaubilder und streamt kompatible Videos direkt per HTTP an den Browser.

Projektseite ↗
HTTPShttps://zanvex.de/git/mini-video-library.git

internal/plugins/service.go

Zum Verzeichnis
package plugins

import (
	"context"
	"database/sql"
	"encoding/json"
	"encoding/xml"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"
)

type Service struct {
	db     *sql.DB
	client *http.Client
}

var aniDBImageBaseURL = "https://cdn.anidb.net/images/main/"

func New(db *sql.DB) *Service {
	return &Service{db: db, client: &http.Client{Timeout: 30 * time.Second}}
}

type Info struct {
	ID, Name, Description string
	Enabled, Configured   bool
}

type OnlineSettings struct {
	TMDBConfigured bool
	AniDBClient    string
	AniDBClientVer string
}

func (s *Service) OnlineSettings(ctx context.Context) OnlineSettings {
	return OnlineSettings{
		TMDBConfigured: s.setting(ctx, "online-metadata", "tmdb_token") != "",
		AniDBClient:    s.setting(ctx, "online-metadata", "anidb_client"),
		AniDBClientVer: s.setting(ctx, "online-metadata", "anidb_clientver"),
	}
}

func (s *Service) List(ctx context.Context) []Info {
	return []Info{{"online-metadata", "AniDB / TMDB", "Online-Metadaten mit dauerhaftem lokalem Cache", true, s.setting(ctx, "online-metadata", "tmdb_token") != ""}, {"nfo", "NFO-Dateien", "Lokale NFO-Dateien lesen und auf Anforderung schreiben", true, true}, {"metadata-editor", "Metadaten-Editor", "Lokale Metadaten bearbeiten", true, true}, {"filename-recognition", "Dateinamen-Erkennung", "Serienname, Staffel, Episode, Release-Gruppe und Qualität erkennen", true, true}, {"subtitle-manager", "Untertitel-Manager", "Lokale Untertiteldateien erkennen", true, true}, {"track-manager", "Spuren-Manager", "Audio- und Untertitelsprachen anzeigen und bevorzugen", true, true}, {"smart-collections", "Smart Collections", "Dynamische lokale Sammlungen", true, true}, {"media-diagnostics", "Medienprüfung", "Fehlende Dateien, Spuren und Metadaten erkennen", true, true}}
}
func (s *Service) setting(ctx context.Context, p, k string) string {
	var v string
	_ = s.db.QueryRowContext(ctx, "SELECT value FROM plugin_settings WHERE plugin=? AND key=?", p, k).Scan(&v)
	return v
}
func (s *Service) SaveSettings(ctx context.Context, p string, values map[string]string) error {
	tx, e := s.db.BeginTx(ctx, nil)
	if e != nil {
		return e
	}
	defer tx.Rollback()
	for k, v := range values {
		if _, e = tx.ExecContext(ctx, "INSERT INTO plugin_settings(plugin,key,value) VALUES(?,?,?) ON CONFLICT(plugin,key) DO UPDATE SET value=excluded.value", p, k, strings.TrimSpace(v)); e != nil {
			return e
		}
	}
	return tx.Commit()
}

type TMDBResult struct {
	ID            int    `json:"id"`
	Title         string `json:"title"`
	OriginalTitle string `json:"original_title"`
	Overview      string `json:"overview"`
	ReleaseDate   string `json:"release_date"`
	PosterPath    string `json:"poster_path"`
	MediaType     string `json:"media_type"`
}

func (s *Service) TMDBSearch(ctx context.Context, query string) ([]TMDBResult, error) {
	query = strings.TrimSpace(query)
	if query == "" {
		return nil, errors.New("empty query")
	}
	key := "search:" + strings.ToLower(query)
	var payload string
	if s.db.QueryRowContext(ctx, "SELECT payload FROM plugin_cache WHERE plugin='online-metadata' AND cache_key=? AND expires_at>?", key, time.Now().Unix()).Scan(&payload) == nil {
		var x []TMDBResult
		return x, json.Unmarshal([]byte(payload), &x)
	}
	token := s.setting(ctx, "online-metadata", "tmdb_token")
	if token == "" {
		return nil, errors.New("TMDB token is not configured")
	}
	u := "https://api.themoviedb.org/3/search/multi?language=de-DE&query=" + url.QueryEscape(query)
	req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
	req.Header.Set("Authorization", "Bearer "+token)
	resp, e := s.client.Do(req)
	if e != nil {
		return nil, e
	}
	defer resp.Body.Close()
	if resp.StatusCode != 200 {
		return nil, fmt.Errorf("TMDB HTTP %s", resp.Status)
	}
	var raw struct {
		Results []TMDBResult `json:"results"`
	}
	if e = json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(&raw); e != nil {
		return nil, e
	}
	raw.Results = filterMedia(raw.Results)
	b, _ := json.Marshal(raw.Results)
	_, _ = s.db.ExecContext(ctx, "INSERT INTO plugin_cache(plugin,cache_key,payload,fetched_at,expires_at)VALUES('online-metadata',?,?,?,?) ON CONFLICT(plugin,cache_key) DO UPDATE SET payload=excluded.payload,fetched_at=excluded.fetched_at,expires_at=excluded.expires_at", key, string(b), time.Now().Unix(), time.Now().Add(30*24*time.Hour).Unix())
	return raw.Results, nil
}
func filterMedia(in []TMDBResult) []TMDBResult {
	out := in[:0]
	for _, x := range in {
		if x.MediaType == "movie" || x.MediaType == "tv" {
			out = append(out, x)
		}
	}
	return out
}
func (s *Service) ApplyTMDB(ctx context.Context, videoID int64, x TMDBResult) error {
	ids, _ := json.Marshal(map[string]any{"tmdb": x.ID, "type": x.MediaType})
	_, e := s.db.ExecContext(ctx, "UPDATE videos SET title=?,description=?,external_ids=? WHERE id=?", x.Title, x.Overview, string(ids), videoID)
	return e
}

type NFO struct {
	XMLName       xml.Name     `xml:"movie"`
	Title         string       `xml:"title"`
	OriginalTitle string       `xml:"originaltitle,omitempty"`
	Plot          string       `xml:"plot,omitempty"`
	Tags          []string     `xml:"tag,omitempty"`
	UniqueIDs     []UniqueID   `xml:"uniqueid,omitempty"`
	AniDB         *AniDBResult `xml:"anidb,omitempty"`
}
type UniqueID struct {
	Type  string `xml:"type,attr"`
	Value string `xml:",chardata"`
}

func (s *Service) WriteNFO(ctx context.Context, id int64) error {
	var path, title, desc, tags, ids, anidbJSON string
	if e := s.db.QueryRowContext(ctx, "SELECT path,title,description,tags,external_ids,anidb_json FROM videos WHERE id=?", id).Scan(&path, &title, &desc, &tags, &ids, &anidbJSON); e != nil {
		return e
	}
	n := NFO{Title: title, Plot: desc}
	for _, t := range strings.Split(tags, ",") {
		if t = strings.TrimSpace(t); t != "" {
			n.Tags = append(n.Tags, t)
		}
	}
	var ext map[string]any
	json.Unmarshal([]byte(ids), &ext)
	for k, v := range ext {
		n.UniqueIDs = append(n.UniqueIDs, UniqueID{k, fmt.Sprint(v)})
	}
	var anidb AniDBResult
	if json.Unmarshal([]byte(anidbJSON), &anidb) == nil && anidb.ID != 0 {
		n.AniDB = &anidb
		n.OriginalTitle = anidb.OriginalTitle()
		for _, tag := range anidb.Tags {
			if tag.Name != "" {
				n.Tags = append(n.Tags, tag.Name)
			}
		}
	}
	b, e := xml.MarshalIndent(n, "", "  ")
	if e != nil {
		return e
	}
	return os.WriteFile(strings.TrimSuffix(path, filepath.Ext(path))+".nfo", append([]byte(xml.Header), b...), 0644)
}
func (s *Service) ReadNFO(ctx context.Context, id int64) error {
	var path string
	if e := s.db.QueryRowContext(ctx, "SELECT path FROM videos WHERE id=?", id).Scan(&path); e != nil {
		return e
	}
	b, e := os.ReadFile(strings.TrimSuffix(path, filepath.Ext(path)) + ".nfo")
	if e != nil {
		return e
	}
	var n NFO
	if e = xml.Unmarshal(b, &n); e != nil {
		return e
	}
	ext := map[string]string{}
	for _, x := range n.UniqueIDs {
		ext[x.Type] = x.Value
	}
	j, _ := json.Marshal(ext)
	anidbJSON := "{}"
	if n.AniDB != nil {
		if encoded, err := json.Marshal(n.AniDB); err == nil {
			anidbJSON = string(encoded)
		}
	}
	_, e = s.db.ExecContext(ctx, "UPDATE videos SET title=?,description=?,tags=?,external_ids=?,anidb_json=? WHERE id=?", n.Title, n.Plot, strings.Join(n.Tags, ", "), string(j), anidbJSON, id)
	return e
}
func (s *Service) WriteAllNFO(ctx context.Context) (int, error) {
	rows, e := s.db.QueryContext(ctx, "SELECT id FROM videos")
	if e != nil {
		return 0, e
	}
	var ids []int64
	for rows.Next() {
		var id int64
		rows.Scan(&id)
		ids = append(ids, id)
	}
	rows.Close()
	count := 0
	for _, id := range ids {
		if e = s.WriteNFO(ctx, id); e != nil {
			return count, e
		}
		count++
	}
	return count, nil
}
func parseInt(v string) (int64, error) { return strconv.ParseInt(v, 10, 64) }

type AniDBResult struct {
	ID              int                   `json:"id" xml:"id,attr"`
	Restricted      bool                  `json:"restricted" xml:"restricted,attr"`
	Title           string                `json:"title" xml:"title"`
	Type            string                `json:"type" xml:"type"`
	Episodes        int                   `json:"episodes" xml:"episodecount"`
	StartDate       string                `json:"start_date" xml:"startdate,omitempty"`
	EndDate         string                `json:"end_date" xml:"enddate,omitempty"`
	Description     string                `json:"description" xml:"description,omitempty"`
	Picture         string                `json:"picture" xml:"picture,omitempty"`
	URL             string                `json:"url" xml:"url,omitempty"`
	Titles          []AniDBTitle          `json:"titles" xml:"titles>title,omitempty"`
	Related         []AniDBRelated        `json:"related" xml:"relatedanime>anime,omitempty"`
	Similar         []AniDBSimilar        `json:"similar" xml:"similaranime>anime,omitempty"`
	Recommendations []AniDBRecommendation `json:"recommendations" xml:"recommendations>recommendation,omitempty"`
	Creators        []AniDBCreator        `json:"creators" xml:"creators>name,omitempty"`
	Ratings         AniDBRatings          `json:"ratings" xml:"ratings"`
	Resources       []AniDBResource       `json:"resources" xml:"resources>resource,omitempty"`
	Tags            []AniDBTag            `json:"tags" xml:"tags>tag,omitempty"`
	Characters      []AniDBCharacter      `json:"characters" xml:"characters>character,omitempty"`
	EpisodeList     []AniDBEpisode        `json:"episode_list" xml:"episodes>episode,omitempty"`
}

type AniDBTitle struct {
	Language string `json:"language" xml:"lang,attr,omitempty"`
	Kind     string `json:"kind" xml:"type,attr,omitempty"`
	Text     string `json:"text" xml:",chardata"`
}
type AniDBRelated struct {
	ID   int    `json:"id" xml:"id,attr"`
	Type string `json:"type" xml:"type,attr,omitempty"`
	Text string `json:"title" xml:",chardata"`
}
type AniDBSimilar struct {
	ID       int    `json:"id" xml:"id,attr"`
	Approval int    `json:"approval" xml:"approval,attr,omitempty"`
	Total    int    `json:"total" xml:"total,attr,omitempty"`
	Text     string `json:"title" xml:",chardata"`
}
type AniDBRecommendation struct {
	Type string `json:"type" xml:"type,attr,omitempty"`
	UID  int    `json:"uid" xml:"uid,attr,omitempty"`
	Text string `json:"text" xml:",chardata"`
}
type AniDBCreator struct {
	ID   int    `json:"id" xml:"id,attr"`
	Type string `json:"type" xml:"type,attr,omitempty"`
	Name string `json:"name" xml:",chardata"`
}
type AniDBRating struct {
	Count int     `json:"count" xml:"count,attr,omitempty"`
	Votes int     `json:"votes" xml:"votes,attr,omitempty"`
	Value float64 `json:"value" xml:",chardata"`
}
type AniDBRatings struct {
	Permanent AniDBRating `json:"permanent" xml:"permanent"`
	Temporary AniDBRating `json:"temporary" xml:"temporary"`
	Review    AniDBRating `json:"review" xml:"review"`
}
type AniDBExternalEntity struct {
	Identifiers []string `json:"identifiers" xml:"identifier,omitempty"`
	URLs        []string `json:"urls" xml:"url,omitempty"`
}
type AniDBResource struct {
	Type     int                   `json:"type" xml:"type,attr"`
	Entities []AniDBExternalEntity `json:"entities" xml:"externalentity,omitempty"`
}
type AniDBTag struct {
	ID            int    `json:"id" xml:"id,attr"`
	ParentID      int    `json:"parent_id" xml:"parentid,attr,omitempty"`
	Weight        int    `json:"weight" xml:"weight,attr,omitempty"`
	LocalSpoiler  bool   `json:"local_spoiler" xml:"localspoiler,attr,omitempty"`
	GlobalSpoiler bool   `json:"global_spoiler" xml:"globalspoiler,attr,omitempty"`
	Verified      bool   `json:"verified" xml:"verified,attr,omitempty"`
	Infobox       bool   `json:"infobox" xml:"infobox,attr,omitempty"`
	Updated       string `json:"updated" xml:"update,attr,omitempty"`
	Name          string `json:"name" xml:"name"`
	Description   string `json:"description" xml:"description,omitempty"`
	Picture       string `json:"picture" xml:"picurl,omitempty"`
}
type AniDBCharacterType struct {
	ID   int    `json:"id" xml:"id,attr"`
	Name string `json:"name" xml:",chardata"`
}
type AniDBSeiyuu struct {
	ID      int    `json:"id" xml:"id,attr"`
	Picture string `json:"picture" xml:"picture,attr,omitempty"`
	Name    string `json:"name" xml:",chardata"`
}
type AniDBCharacter struct {
	ID            int                `json:"id" xml:"id,attr"`
	Type          string             `json:"type" xml:"type,attr,omitempty"`
	Updated       string             `json:"updated" xml:"update,attr,omitempty"`
	Name          string             `json:"name" xml:"name"`
	Gender        string             `json:"gender" xml:"gender,omitempty"`
	CharacterType AniDBCharacterType `json:"character_type" xml:"charactertype"`
	Description   string             `json:"description" xml:"description,omitempty"`
	Picture       string             `json:"picture" xml:"picture,omitempty"`
	Rating        AniDBRating        `json:"rating" xml:"rating"`
	Seiyuu        *AniDBSeiyuu       `json:"seiyuu,omitempty" xml:"seiyuu,omitempty"`
}
type AniDBEpisodeNumber struct {
	Type int    `json:"type" xml:"type,attr,omitempty"`
	Text string `json:"text" xml:",chardata"`
}
type AniDBEpisode struct {
	ID      int                `json:"id" xml:"id,attr"`
	Updated string             `json:"updated" xml:"update,attr,omitempty"`
	Number  AniDBEpisodeNumber `json:"number" xml:"epno"`
	Length  int                `json:"length" xml:"length"`
	AirDate string             `json:"air_date" xml:"airdate,omitempty"`
	Titles  []AniDBTitle       `json:"titles" xml:"title,omitempty"`
	Rating  AniDBRating        `json:"rating" xml:"rating"`
	Summary string             `json:"summary" xml:"summary,omitempty"`
}

func (a AniDBResult) OriginalTitle() string {
	for _, title := range a.Titles {
		if title.Kind == "official" && title.Language == "ja" {
			return title.Text
		}
	}
	return ""
}

func (s *Service) AniDBLookup(ctx context.Context, aid int) (AniDBResult, error) {
	key := "anidb:v2:" + strconv.Itoa(aid)
	var payload string
	if s.db.QueryRowContext(ctx, "SELECT payload FROM plugin_cache WHERE plugin='online-metadata' AND cache_key=? AND expires_at>?", key, time.Now().Unix()).Scan(&payload) == nil {
		var out AniDBResult
		return out, json.Unmarshal([]byte(payload), &out)
	}
	client := s.setting(ctx, "online-metadata", "anidb_client")
	ver := s.setting(ctx, "online-metadata", "anidb_clientver")
	if client == "" || ver == "" {
		return AniDBResult{}, errors.New("AniDB ist noch nicht konfiguriert. Unter Einstellungen > Plugins bitte Clientname und Clientversion eintragen")
	}
	u := "http://api.anidb.net:9001/httpapi?request=anime&protover=1&client=" + url.QueryEscape(strings.ToLower(client)) + "&clientver=" + url.QueryEscape(ver) + "&aid=" + strconv.Itoa(aid)
	req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
	resp, e := s.client.Do(req)
	if e != nil {
		return AniDBResult{}, e
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		message, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
		if text := strings.TrimSpace(string(message)); text != "" {
			return AniDBResult{}, fmt.Errorf("AniDB HTTP %s: %s", resp.Status, text)
		}
		return AniDBResult{}, fmt.Errorf("AniDB HTTP %s", resp.Status)
	}
	out, e := decodeAniDB(io.LimitReader(resp.Body, 16<<20))
	if e != nil {
		return AniDBResult{}, e
	}

	b, _ := json.Marshal(out)
	_, _ = s.db.ExecContext(ctx, "INSERT INTO plugin_cache(plugin,cache_key,payload,fetched_at,expires_at)VALUES('online-metadata',?,?,?,?) ON CONFLICT(plugin,cache_key) DO UPDATE SET payload=excluded.payload,fetched_at=excluded.fetched_at,expires_at=excluded.expires_at", key, string(b), time.Now().Unix(), time.Now().Add(24*time.Hour).Unix())
	return out, nil
}

func decodeAniDB(r io.Reader) (AniDBResult, error) {
	b, err := io.ReadAll(r)
	if err != nil {
		return AniDBResult{}, err
	}
	var apiError struct {
		XMLName xml.Name
		Message string `xml:",chardata"`
	}
	if xml.Unmarshal(b, &apiError) == nil && apiError.XMLName.Local == "error" {
		return AniDBResult{}, fmt.Errorf("AniDB: %s", strings.TrimSpace(apiError.Message))
	}
	var out AniDBResult
	if err = xml.Unmarshal(b, &out); err != nil {
		return AniDBResult{}, err
	}
	if out.ID == 0 {
		return AniDBResult{}, errors.New("AniDB lieferte keine Anime-Daten")
	}
	for _, title := range out.Titles {
		if title.Kind == "main" || (out.Title == "" && title.Kind == "official") {
			out.Title = title.Text
		}
	}
	return out, nil
}
func (s *Service) ApplyAniDB(ctx context.Context, videoID int64, x AniDBResult) error {
	ids, _ := json.Marshal(map[string]any{"anidb": x.ID})
	metadata, _ := json.Marshal(x)
	if _, e := s.db.ExecContext(ctx, "UPDATE videos SET title=?,description=?,external_ids=?,anidb_json=? WHERE id=?", x.Title, x.Description, string(ids), string(metadata), videoID); e != nil {
		return e
	}
	return s.WriteNFO(ctx, videoID)
}

func (s *Service) DownloadAniDBPoster(ctx context.Context, videoID int64, picture string) (string, error) {
	picture = filepath.Base(strings.TrimSpace(picture))
	ext := strings.ToLower(filepath.Ext(picture))
	if picture == "." || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp") {
		return "", errors.New("AniDB lieferte keinen gültigen Bildnamen")
	}
	var videoPath string
	if err := s.db.QueryRowContext(ctx, "SELECT path FROM videos WHERE id=?", videoID).Scan(&videoPath); err != nil {
		return "", err
	}
	base := strings.TrimSuffix(filepath.Base(videoPath), filepath.Ext(videoPath))
	target := filepath.Join(filepath.Dir(videoPath), base+".anidb-cover"+ext)
	if _, err := os.Stat(target); err == nil {
		_, err = s.db.ExecContext(ctx, "UPDATE videos SET poster=? WHERE id=?", target, videoID)
		return target, err
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, aniDBImageBaseURL+url.PathEscape(picture), nil)
	if err != nil {
		return "", err
	}
	resp, err := s.client.Do(req)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("AniDB-Cover HTTP %s", resp.Status)
	}
	if contentType := resp.Header.Get("Content-Type"); contentType != "" && !strings.HasPrefix(contentType, "image/") {
		return "", fmt.Errorf("AniDB-Cover hat ungültigen Inhaltstyp %s", contentType)
	}
	tmp, err := os.CreateTemp(filepath.Dir(videoPath), ".anidb-cover-*")
	if err != nil {
		return "", err
	}
	tmpPath := tmp.Name()
	defer os.Remove(tmpPath)
	written, copyErr := io.Copy(tmp, io.LimitReader(resp.Body, (12<<20)+1))
	closeErr := tmp.Close()
	if copyErr != nil {
		return "", copyErr
	}
	if closeErr != nil {
		return "", closeErr
	}
	if written > 12<<20 {
		return "", errors.New("AniDB-Cover ist größer als 12 MiB")
	}
	if err = os.Rename(tmpPath, target); err != nil {
		return "", err
	}
	_, err = s.db.ExecContext(ctx, "UPDATE videos SET poster=? WHERE id=?", target, videoID)
	return target, err
}