mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 19:36:36 +08:00
381 lines
10 KiB
Go
381 lines
10 KiB
Go
package service
|
||
|
||
import (
|
||
"net/url"
|
||
"path"
|
||
"regexp"
|
||
"strings"
|
||
)
|
||
|
||
var strmTreeListPrefixRE = regexp.MustCompile(`^\s*(?:[-*+•·]\s+|\d{1,4}[\.)、]\s+)`)
|
||
|
||
var strmTreeURLPathQueryKeys = []string{"path", "ref", "file", "filepath", "filename", "name"}
|
||
|
||
var strmTreeKnownNonVideoExtensions = map[string]struct{}{
|
||
".ass": {},
|
||
".bmp": {},
|
||
".gif": {},
|
||
".idx": {},
|
||
".jpeg": {},
|
||
".jpg": {},
|
||
".nfo": {},
|
||
".png": {},
|
||
".srt": {},
|
||
".ssa": {},
|
||
".strm": {},
|
||
".sub": {},
|
||
".txt": {},
|
||
".url": {},
|
||
".vtt": {},
|
||
".webp": {},
|
||
}
|
||
|
||
var strmTreeSubtitleExtensions = map[string]struct{}{
|
||
".ass": {},
|
||
".idx": {},
|
||
".srt": {},
|
||
".ssa": {},
|
||
".sub": {},
|
||
".vtt": {},
|
||
}
|
||
|
||
func normalizeSTRMTreeProvider(raw string) string {
|
||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||
case "115", "115pan", "pan115", "cloud115":
|
||
return "cloud115"
|
||
case "alist", "openlist", "webdav", "clouddrive2", "s3":
|
||
return strings.ToLower(strings.TrimSpace(raw))
|
||
default:
|
||
return strings.ToLower(strings.TrimSpace(raw))
|
||
}
|
||
}
|
||
|
||
func normalizeSTRMTreeSource(raw string) string {
|
||
value := strings.TrimSpace(strings.Trim(raw, `"'`))
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if strings.HasPrefix(strings.ToLower(value), "cloud://") {
|
||
parts := strmLibraryPathParts(value)
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return "/" + path.Join(parts...)
|
||
}
|
||
if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
|
||
if ref := strmTreeSourceFromURL(parsed); ref != "" {
|
||
value = ref
|
||
}
|
||
}
|
||
value = trimSTRMTreeMediaMetadata(value)
|
||
value = strings.ReplaceAll(value, "\\", "/")
|
||
value = pathAfterWindowsDrivePrefix(value)
|
||
value = cleanSTRMTreePathDecorations(value)
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if strmTreeHasUnsafeSegment(value) {
|
||
return strings.Trim(value, "/")
|
||
}
|
||
if strings.HasPrefix(value, "/") {
|
||
return path.Clean(value)
|
||
}
|
||
return strings.TrimPrefix(path.Clean("/"+value), "/")
|
||
}
|
||
|
||
func normalizeSTRMTreeSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
|
||
provider := normalizeSTRMTreeProvider(fallbackProvider)
|
||
value := strings.TrimSpace(strings.Trim(raw, `"'`))
|
||
if value == "" {
|
||
return strmTreeSource{}
|
||
}
|
||
if info, ok := ParseCloudLibraryMount(value); ok {
|
||
source := normalizeSTRMTreeSource(info.DisplayDir)
|
||
if source == "" {
|
||
return strmTreeSource{}
|
||
}
|
||
ref := normalizeSTRMTreeSource(info.ScanDir)
|
||
if ref == "" {
|
||
ref = source
|
||
}
|
||
return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref}
|
||
}
|
||
if parsed, err := url.Parse(value); err == nil {
|
||
if cloudProvider, ref := strmTreeCloudPlaySourceFromURL(parsed); cloudProvider != "" && ref != "" {
|
||
source := normalizeSTRMTreeSource(ref)
|
||
if source == "" {
|
||
return strmTreeSource{}
|
||
}
|
||
return strmTreeSource{Provider: normalizeSTRMTreeProvider(cloudProvider), Path: source}
|
||
}
|
||
}
|
||
source := normalizeSTRMTreeSource(value)
|
||
if source == "" {
|
||
return strmTreeSource{}
|
||
}
|
||
return strmTreeSource{Provider: provider, Path: source}
|
||
}
|
||
|
||
func normalizeSTRMTreeSubtitleSourceWithProvider(raw, fallbackProvider string) strmTreeSource {
|
||
provider := normalizeSTRMTreeProvider(fallbackProvider)
|
||
value := strings.TrimSpace(strings.Trim(raw, `"'`))
|
||
if value == "" {
|
||
return strmTreeSource{}
|
||
}
|
||
if info, ok := ParseCloudLibraryMount(value); ok {
|
||
source, sourceOK := strmTreeSubtitleFileLikeSource(info.DisplayDir)
|
||
if !sourceOK {
|
||
return strmTreeSource{}
|
||
}
|
||
ref, refOK := strmTreeSubtitleFileLikeSource(info.ScanDir)
|
||
if !refOK {
|
||
ref = source
|
||
}
|
||
return strmTreeSource{Provider: normalizeSTRMTreeProvider(info.Provider), Path: source, RefPath: ref, Kind: strmTreeSourceKindSubtitle}
|
||
}
|
||
source, ok := strmTreeSubtitleFileLikeSource(value)
|
||
if !ok {
|
||
return strmTreeSource{}
|
||
}
|
||
return strmTreeSource{Provider: provider, Path: source, Kind: strmTreeSourceKindSubtitle}
|
||
}
|
||
|
||
func strmTreeCloudPlaySourceFromURL(parsed *url.URL) (string, string) {
|
||
if parsed == nil {
|
||
return "", ""
|
||
}
|
||
parts := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||
if len(parts) < 4 || !strings.EqualFold(parts[0], "api") || !strings.EqualFold(parts[1], "cloud") || !strings.EqualFold(parts[2], "play") {
|
||
return "", ""
|
||
}
|
||
provider := strings.TrimSpace(parts[3])
|
||
if decoded, err := url.PathUnescape(provider); err == nil {
|
||
provider = decoded
|
||
}
|
||
// /api/cloud/play is generated by this app with url.Values, where '+'
|
||
// represents a space. Decode the standard query view first so re-importing
|
||
// existing STRM links does not turn "My Movie.mkv" into "My+Movie.mkv".
|
||
for _, value := range parsed.Query()["ref"] {
|
||
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
|
||
return provider, ref
|
||
}
|
||
}
|
||
for _, value := range strmTreeRawQueryValues(parsed.RawQuery, "ref") {
|
||
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
|
||
return provider, ref
|
||
}
|
||
}
|
||
return "", ""
|
||
}
|
||
|
||
func strmTreeSourceFromURL(parsed *url.URL) string {
|
||
if parsed == nil {
|
||
return ""
|
||
}
|
||
for _, key := range strmTreeURLPathQueryKeys {
|
||
for _, value := range strmTreeRawQueryValues(parsed.RawQuery, key) {
|
||
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
|
||
return ref
|
||
}
|
||
}
|
||
for _, value := range parsed.Query()[key] {
|
||
if ref := strmTreeURLQueryMediaSource(value); ref != "" {
|
||
return ref
|
||
}
|
||
}
|
||
}
|
||
if parsed.Path != "" && strmTreeURLQueryMediaSource(parsed.Path) != "" {
|
||
return parsed.Path
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func strmTreeRawQueryValues(rawQuery, key string) []string {
|
||
key = strings.ToLower(strings.TrimSpace(key))
|
||
if rawQuery == "" || key == "" {
|
||
return nil
|
||
}
|
||
values := make([]string, 0, 1)
|
||
for _, pair := range strings.Split(rawQuery, "&") {
|
||
if pair == "" {
|
||
continue
|
||
}
|
||
rawKey, rawValue, _ := strings.Cut(pair, "=")
|
||
decodedKey, err := url.QueryUnescape(rawKey)
|
||
if err != nil {
|
||
decodedKey = rawKey
|
||
}
|
||
if strings.ToLower(strings.TrimSpace(decodedKey)) != key {
|
||
continue
|
||
}
|
||
decodedValue, err := url.PathUnescape(rawValue)
|
||
if err != nil {
|
||
decodedValue = rawValue
|
||
}
|
||
values = append(values, decodedValue)
|
||
}
|
||
return values
|
||
}
|
||
|
||
func strmTreeURLQueryMediaSource(raw string) string {
|
||
value := strings.TrimSpace(strings.Trim(raw, `"'`))
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
if parsed, err := url.Parse(value); err == nil && parsed.Scheme != "" && parsed.Scheme != "file" {
|
||
value = parsed.Path
|
||
}
|
||
value = trimSTRMTreeMediaMetadata(value)
|
||
if strmTreeSourceIsVideo(value) {
|
||
return value
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func strmTreeHasUnsafeSegment(value string) bool {
|
||
for _, part := range strings.Split(strings.ReplaceAll(value, "\\", "/"), "/") {
|
||
if strings.TrimSpace(part) == ".." {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func strmTreeSourceIsVideo(source string) bool {
|
||
ext := strings.ToLower(path.Ext(trimSTRMTreeMediaMetadata(cleanSTRMTreeItemName(source))))
|
||
if ext == ".strm" {
|
||
return false
|
||
}
|
||
_, ok := videoExtensions[ext]
|
||
return ok
|
||
}
|
||
|
||
func strmTreeIgnoredFileLikeSource(raw string) (string, bool) {
|
||
source := normalizeSTRMTreeSource(raw)
|
||
if source == "" {
|
||
return "", false
|
||
}
|
||
source = trimSTRMTreeKnownFileMetadata(cleanSTRMTreeItemName(source))
|
||
ext := strings.ToLower(path.Ext(source))
|
||
if ext == "" {
|
||
return "", false
|
||
}
|
||
if ext == ".strm" {
|
||
return source, true
|
||
}
|
||
if _, ok := videoExtensions[ext]; ok {
|
||
return "", false
|
||
}
|
||
if _, ok := strmTreeKnownNonVideoExtensions[ext]; ok {
|
||
return source, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func strmTreeSubtitleFileLikeSource(raw string) (string, bool) {
|
||
source, ok := strmTreeIgnoredFileLikeSource(raw)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
ext := strings.ToLower(path.Ext(source))
|
||
if _, ok := strmTreeSubtitleExtensions[ext]; !ok {
|
||
return "", false
|
||
}
|
||
return source, true
|
||
}
|
||
|
||
func cleanSTRMTreePathDecorations(value string) string {
|
||
if value == "" {
|
||
return ""
|
||
}
|
||
leadingSlash := strings.HasPrefix(value, "/")
|
||
parts := strings.Split(value, "/")
|
||
for i, part := range parts {
|
||
if part == "" {
|
||
continue
|
||
}
|
||
parts[i] = cleanSTRMTreeItemName(part)
|
||
}
|
||
cleaned := strings.Join(parts, "/")
|
||
if leadingSlash && !strings.HasPrefix(cleaned, "/") {
|
||
cleaned = "/" + cleaned
|
||
}
|
||
return cleaned
|
||
}
|
||
|
||
func cleanSTRMTreeItemName(value string) string {
|
||
value = strings.TrimSpace(strings.Trim(value, `"'`))
|
||
for {
|
||
before := value
|
||
value = strings.TrimSpace(strmTreeListPrefixRE.ReplaceAllString(value, ""))
|
||
value = strings.TrimSpace(strings.TrimLeft(value, "\ufeff📁📂📄📃🎬🎞🎥📺🗂️"))
|
||
lower := strings.ToLower(value)
|
||
for _, marker := range []string{
|
||
"[file]", "[f]", "[文件]", "[檔案]", "[档案]",
|
||
"[dir]", "[d]", "[folder]", "[目录]", "[目錄]", "[文件夹]", "[資料夾]",
|
||
"(file)", "(f)", "(文件)", "(檔案)", "(档案)",
|
||
"(dir)", "(d)", "(folder)", "(目录)", "(目錄)", "(文件夹)", "(資料夾)",
|
||
} {
|
||
if strings.HasPrefix(lower, marker) {
|
||
value = strings.TrimLeft(strings.TrimSpace(value[len(marker):]), "-_:: ")
|
||
break
|
||
}
|
||
}
|
||
if value == before {
|
||
return value
|
||
}
|
||
}
|
||
}
|
||
|
||
func trimSTRMTreeMediaMetadata(value string) string {
|
||
return trimSTRMTreeMetadataByExtensions(value, videoExtensions)
|
||
}
|
||
|
||
func trimSTRMTreeKnownFileMetadata(value string) string {
|
||
return trimSTRMTreeMetadataByExtensions(value, videoExtensions, strmTreeKnownNonVideoExtensions)
|
||
}
|
||
|
||
func trimSTRMTreeMetadataByExtensions(value string, extensionSets ...map[string]struct{}) string {
|
||
value = strings.TrimSpace(value)
|
||
lower := strings.ToLower(value)
|
||
bestEnd := -1
|
||
for _, extensions := range extensionSets {
|
||
for ext := range extensions {
|
||
from := 0
|
||
for {
|
||
idx := strings.Index(lower[from:], ext)
|
||
if idx < 0 {
|
||
break
|
||
}
|
||
idx += from
|
||
end := idx + len(ext)
|
||
if strmTreeMediaExtBoundary(value, end) && end > bestEnd {
|
||
bestEnd = end
|
||
}
|
||
from = idx + 1
|
||
if from >= len(lower) {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if bestEnd > 0 && bestEnd < len(value) {
|
||
return strings.TrimSpace(value[:bestEnd])
|
||
}
|
||
return value
|
||
}
|
||
|
||
func strmTreeMediaExtBoundary(value string, end int) bool {
|
||
if end >= len(value) {
|
||
return true
|
||
}
|
||
for _, r := range value[end:] {
|
||
if r == '(' || r == '[' || r == '{' || r == '<' || r == '(' || r == '【' || r == ',' || r == ',' {
|
||
return true
|
||
}
|
||
return r == ' ' || r == '\t' || r == '\r' || r == '\n'
|
||
}
|
||
return true
|
||
}
|