fix media organize and subscription workflows

This commit is contained in:
ShukeBta
2026-06-30 20:18:02 +08:00
parent b41f62d572
commit 941bb273b9
107 changed files with 5420 additions and 526 deletions
+284
View File
@@ -0,0 +1,284 @@
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"}
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 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 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 {
value = strings.TrimSpace(value)
lower := strings.ToLower(value)
bestEnd := -1
for ext := range videoExtensions {
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
}