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.
HTTPS
https://zanvex.de/git/mini-video-library.gitinternal/plugins/service.go
Zum Verzeichnispackage 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
}