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 }