commit fe62910311512ba60114fc1bedbcbefcdd0eb287 Author: Vladimir Zagainov Date: Sat Oct 17 17:09:42 2026 +0300 Initial commit: ym-dl with yandex-music-api-go library migration Migrate ym-dl from custom oapi-codegen Yandex Music API client to external gitea.mrixs.me/Mrixs/yandex-music-api-go library with token-only authentication. Key changes: - Replace custom Yandex API client with yandexmusic library wrapper - Token-based OAuth authentication only (no username/password fallback) - SQLite storage index with JSON index migration path - Extended ID3 tag support: genre, comment, lyrics, publisher, ISRC, copyright - High-resolution album covers (1000x1000) - Fixed album download check (requires all tracks, not just one) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1e2baf --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Binaries +bin/ +*.exe + +# OS +.DS_Store +Thumbs.db + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Go +/vendor/ +*.test + +# Docker +.env + +# Local config (may contain credentials) +config.yaml + +# SQLite index +index.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..cb58e4b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**ym-dl** — A Go service for monitoring and downloading tracks/albums from Yandex Music for configured artists. + +- **Language**: Go (strict typing, idiomatic patterns) +- **Runtime**: Docker / Docker Compose +- **API**: Yandex Music (OpenAPI spec at `docs/yandex-music.yaml`) +- **Config**: YAML file listing artist IDs to monitor + +## Architecture + +``` +ym-dl/ +├── cmd/ym-dl/ # Main entry point +├── internal/ +│ ├── config/ # Configuration loading (artist IDs, API credentials) +│ ├── yandex/ # Yandex Music API client (generated from OpenAPI) +│ ├── storage/ # Local file storage for downloaded tracks/albums +│ ├── monitor/ # Periodic check logic for new releases +│ └── downloader/ # Download orchestration +├── docker-compose.yml # Service definition +├── Dockerfile # Multi-stage build +└── config.yaml.example # Sample configuration +``` + +## Development Commands + +```bash +# Build +go build -o bin/ym-dl ./cmd/ym-dl + +# Run locally +go run ./cmd/ym-dl --config config.yaml + +# Run with Docker Compose +docker compose up --build + +# Run tests +go test ./... + +# Run single test +go test -v -run TestName ./internal/... + +# Lint +golangci-lint run + +# Generate API client from OpenAPI +oapi-codegen -generate types,client -package yandex docs/yandex-music.yaml > internal/yandex/client.gen.go +``` + +## Configuration + +`config.yaml` structure: +```yaml +yandex: + token: "oauth_token" # Or use username/password for token exchange + username: "login" + password: "password" + +artists: + - id: 12345 # Artist ID from Yandex Music + - id: 67890 + +storage: + path: "/data/music" # Local path for downloads + organize: true # Organize into Artist/Album/Track structure + +monitor: + interval: "24h" # Check interval (duration string) +``` + +## Key Implementation Notes + +- **Authentication**: Support both OAuth token and username/password (token exchange via `/token` endpoint) +- **Rate limiting**: Respect Yandex API limits; implement exponential backoff +- **Idempotency**: Track downloaded files by Yandex track/album ID to avoid re-downloads +- **Progress**: Structured logging (JSON) for Docker environments + +## Docker + +Multi-stage build: builder (Go 1.23+) → runtime (distroless or alpine). Mount config at `/config.yaml`, storage at `/data`. + +## Testing + +- Unit tests for config, storage, monitor logic +- Integration tests against mocked Yandex API (use `net/http/httptest`) +- No real API calls in CI \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..597d0c4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM golang:1.24-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git make + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source +COPY . . + +# Tidy and build binary +RUN go mod tidy && CGO_ENABLED=0 GOOS=linux go build -o /ym-dl ./cmd/ym-dl + +# Runtime stage +FROM gcr.io/distroless/static-debian12:nonroot + +WORKDIR /app + +# Copy binary +COPY --from=builder /ym-dl /app/ym-dl + +# Copy config example +COPY config.yaml.example /app/config.yaml.example + +# Run as non-root user (distroless uses nonroot:nonroot by default) +USER nonroot:nonroot + +ENTRYPOINT ["/app/ym-dl"] +CMD ["--config", "/config/config.yaml"] \ No newline at end of file diff --git a/cmd/ym-dl/main.go b/cmd/ym-dl/main.go new file mode 100644 index 0000000..3fdd71d --- /dev/null +++ b/cmd/ym-dl/main.go @@ -0,0 +1,93 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "ym-dl/internal/config" + "ym-dl/internal/downloader" + "ym-dl/internal/monitor" + "ym-dl/internal/storage" + "ym-dl/internal/yandex" +) + +func main() { + configPath := flag.String("config", "config.yaml", "Path to config file") + flag.Parse() + + // Load configuration + cfg, err := config.Load(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) + os.Exit(1) + } + + // Setup logger + logLevel := slog.LevelInfo + if cfg.Debug { + logLevel = slog.LevelDebug + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + })) + slog.SetDefault(logger) + + // Create Yandex client + yandexClient, err := yandex.NewYandexClient(&cfg.Yandex, cfg.Debug) + if err != nil { + logger.Error("Failed to create Yandex client", "error", err) + os.Exit(1) + } + + // Create storage + store, err := storage.NewStorage(cfg.Storage.Path, cfg.Storage.Organize) + if err != nil { + logger.Error("Failed to create storage", "error", err) + os.Exit(1) + } + + // Create downloader + dl := downloader.NewDownloader(yandexClient, store) + + // Create monitor + mon := monitor.NewMonitor(cfg, yandexClient, store, dl, logger) + + // Setup context with signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + logger.Info("Shutdown signal received") + cancel() + mon.Stop() + if err := store.Close(); err != nil { + logger.Error("Failed to close storage", "error", err) + } + }() + + // Start monitor + if err := mon.Start(ctx); err != nil { + logger.Error("Failed to start monitor", "error", err) + os.Exit(1) + } + + logger.Info("ym-dl started", "artists", len(cfg.Artists), "storage", cfg.Storage.Path) + + // Wait for context cancellation + <-ctx.Done() + + mon.Stop() + if err := store.Close(); err != nil { + logger.Error("Failed to close storage", "error", err) + } + + logger.Info("ym-dl stopped") +} \ No newline at end of file diff --git a/config.yaml.example b/config.yaml.example new file mode 100644 index 0000000..e413575 --- /dev/null +++ b/config.yaml.example @@ -0,0 +1,22 @@ +# Yandex Music Downloader Configuration +# Copy to config.yaml and fill in your values + +yandex: + # Option 1: OAuth token (preferred) + token: "" + # Option 2: Username/password for token exchange (not all accounts support this) + username: "" + password: "" + +artists: + - id: 12345 # Example artist ID - replace with actual Yandex Music artist IDs + - id: 67890 + +storage: + path: "/data/music" # Local path for downloads + organize: true # Organize into Artist/Album/Track structure + +monitor: + interval: "24h" # Check interval (duration string: 1h, 24h, 7d, etc.) + +debug: false # Enable debug logging (HTTP requests/responses) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..deaea15 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,24 @@ +version: '3.8' + +services: + ym-dl: + build: + context: . + dockerfile: Dockerfile + container_name: ym-dl + restart: unless-stopped + volumes: + - ./config.yaml:/config/config.yaml:ro + - ym-dl-data:/data/music + - ./logs:/app/logs + environment: + - TZ=UTC + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + +volumes: + ym-dl-data: + driver: local \ No newline at end of file diff --git a/docs/yandex-music.yaml b/docs/yandex-music.yaml new file mode 100644 index 0000000..85ee8d3 --- /dev/null +++ b/docs/yandex-music.yaml @@ -0,0 +1,4284 @@ +openapi: 3.0.1 +info: + title: Yandex Music Api + description: "Swagger документация для Yandex Music API. Не для всех аккаунтов работает авторизация по логину и паролю, в этом случае необходимо использовать авторизацию с помощью токена." + version: 0.0.1 +servers: + - url: https://api.music.yandex.ru:443/ + - url: https://api.music.yandex.net:443/ + - url: http://api.music.yandex.net:443/ +tags: + - name: "user" + - name: "tracks" + - name: "albums" + - name: "playlists" + - name: "landing" + - name: "artists" + - name: "rotor" + - name: "tags" + +paths: + /token: + servers: + - url: https://oauth.yandex.ru + post: + summary: Получение токена по логину и паролю + operationId: getToken + tags: + - "user" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - "grant_type" + - "client_id" + - "client_secret" + - "username" + - "password" + properties: + grant_type: + type: string + enum: ["password"] + client_id: + type: string + enum: ["23cabbbdc6cd418abb4b39c32c41195d"] + client_secret: + type: string + enum: ["53bc75238f0c4d08a118e51fe9203300"] + username: + type: string + password: + type: string + responses: + 200: + description: Ok + content: + application/json: + schema: + type: object + required: + - "access_token" + - "expires_in" + - "token_type" + - "uid" + properties: + access_token: + type: string + expires_in: + type: number + token_type: + type: string + uid: + type: number + + /settings: + description: Получение предложений по покупке. Нет обязательных параметров + get: + operationId: getSettings + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Settings" + + /permission-alerts: + description: Получение оповещений. Нет обязательных параметров. + get: + operationId: getPermissionAlerts + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/PermissionAlerts" + + /account/experiments: + description: Получение значений экспериментальных функций аккаунта + get: + operationId: getAccountExperiments + tags: + - account + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Experiments" + + /account/consume-promo-code: + description: Активация промо-кода + post: + operationId: activatePromoCode + tags: + - account + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + code: + type: string + language: + type: string + example: en + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/PromoCodeStatus" + + /feed/wizard/is-passed: + get: + #TODO: add operationId + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + properties: + isWizardPassed: + type: boolean + example: true + + /account/settings: + description: Настройки текущего пользователя + get: + operationId: getAccountSettings + summary: Получение настроек текущего пользователя + tags: + - account + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/UserSettings" + post: + operationId: changeAccountSettings + summary: Изменение настроек текущего пользователя + tags: + - account + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + description: Словарь параметров и значений + type: object + additionalProperties: true + example: + theme: "black" + volumePercents: 80 + adsDisabled: true + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + description: Настройки пользователя или "None" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + oneOf: + - $ref: "#/components/schemas/UserSettings" + - type: string + example: "None" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /account/status: + description: Get account status for current user + get: + operationId: getAccountStatus + tags: + - "account" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Status" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + /feed: + get: + operationId: getFeed + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: [invocationInfo, result] + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: + - canGetMoreEvents + - days + - generatedPlaylists + - headlines + - isWizardPassed + - pumpkin + - today + properties: + canGetMoreEvents: + type: boolean + days: + type: array + items: + type: object + generatedPlaylists: + type: array + items: + $ref: "#/components/schemas/GeneratedPlaylistLandingBlock" + headlines: + type: array + items: + type: object + isWizardPassed: + type: boolean + pumpkin: + type: boolean + today: + type: string + + /tags/{tagId}/playlist-ids: + summary: Получение тега (подборки) + parameters: + - in: path + name: tagId + schema: + type: string + required: true + example: "rock" + get: + operationId: getPlaylistsIdsByTag + tags: + - "tags" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/TagResult" + + /albums/{albumId}/: + parameters: + - in: path + name: albumId + schema: + type: number + required: true + example: 7019818 + get: + operationId: getAlbumById + summary: Получение альбома по идентификатору + tags: + - "albums" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Album" + + /albums/{albumId}/with-tracks: + parameters: + - in: path + name: albumId + schema: + type: number + required: true + example: 7019818 + get: + summary: Получение альбома с треками + operationId: getAlbumsWithTracks + tags: + - "albums" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Album" + + /albums: + post: + summary: Получение альбомов по идентификаторам + operationId: getAlbumsByIds + tags: + - "albums" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: ["album-ids"] + properties: + "album-ids": + type: string + example: "18837614,23899279,23754447,21808509,23529901,22231572,10935745,23342620,23190604,23240656" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Album" + + /landing3: + parameters: + - in: query + name: blocks + schema: + type: string + description: Одно или несколько значений блоков, разделенных запятой "promotions","new-releases","new-playlists","mixes","chart","playlists","play_contexts","podcasts" + examples: + single: + value: "new-releases" + all: + value: "personalplaylists,promotions,new-releases,new-playlists,mixes,chart,artists,albums,playlists,play_contexts,podcasts" + get: + operationId: getLandingBlocks + tags: + - "landing" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/LandingResult" + headers: + "X-Yandex-Music-Device": + schema: + type: string + description: Данные заголовок использует Windows приложение Yandex.Music, если пользователь не авторизован + example: "os=unknown; os_version=unknown; manufacturer=unknown; model=unknown; clid=; device_id=unknown; uuid=unknown" + + /landing3/{landingBlock}: + parameters: + - in: path + name: landingBlock + required: true + schema: + type: string + enum: ["new-playlists", "new-releases", "chart", "podcasts"] + get: + operationId: getLandingBlock + tags: + - "landing" + responses: + 200: + description: ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + + /landing3/new-releases: + get: + operationId: getNewReleases + tags: + - "landing" + responses: + 200: + description: ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: [id, type, typeForFrom, title, newReleases] + properties: + id: + type: string + type: + type: string + enum: [new-releases] + typeForFrom: + type: string + title: + type: string + newReleases: + type: array + items: + type: number + + /landing3/podcasts: + get: + operationId: getNewPodcasts + tags: + - "landing" + responses: + 200: + description: ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: [type, typeForFrom, title, podcasts] + properties: + type: + type: string + enum: [non-music_main_podcasts] + typeForFrom: + type: string + title: + type: string + podcasts: + type: array + items: + type: number + + /landing3/new-playlists: + get: + operationId: getNewPlaylists + tags: + - "landing" + responses: + 200: + description: ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: [id, type, typeForFrom, title, newPlaylists] + properties: + id: + type: string + type: + type: string + enum: [new-playlists] + typeForFrom: + type: string + title: + type: string + newPlaylists: + type: array + items: + $ref: "#/components/schemas/NewPlaylistItem" + + /landing3/chart/{chartType}: + parameters: + - in: path + name: chartType + required: true + schema: + type: string + enum: ["russia", "world"] + get: + operationId: getChart + tags: + - "landing" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/FullChartResult" + + /genres: + get: + operationId: getGenres + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Genre" + + /playlists/list: + post: + summary: Получение полной информации о плейлистах по их идентификатору + operationId: getPlaylistsByIds + tags: + - "playlists" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + playlistIds: + type: array + description: uid владельца плейлиста и kind плейлиста через двоеточие и запятую + example: + - "103372440:1878" + - "460140864:1000" + items: + type: string + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Playlist" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /users/{userId}/playlists/list: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + get: + operationId: getPlayLists + summary: Получение списка плейлистов пользователя. + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Playlist" + 405: + description: "Method Not Allowed" + content: {} + + /users/{userId}/dislikes/tracks: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: query + required: false + name: if_modified_since_revision + description: TODO + schema: + type: number + get: + operationId: getDislikedTracksIds + summary: Получение треков с отметкой "Не рекомендовать" + tags: + - "user" + - "tracks" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/TracksList" + /search: + parameters: + - in: query + name: text + required: true + description: Текст запроса + schema: + type: string + example: Король и Шут + - in: query + name: page + required: true + description: Номер страницы выдачи + schema: + type: number + example: 0 + - in: query + name: type + required: true + schema: + $ref: "#/components/schemas/SearchType" + - in: query + name: nococrrect + schema: + type: boolean + default: false + get: + tags: + - "search" + operationId: "search" + summary: Осуществление поиска по запросу и типу, получение результатов + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Search" + + /search/suggest: + parameters: + - in: query + required: true + name: part + description: Часть поискового запроса + schema: + type: string + example: Король + get: + # TODO: add operationId + tags: + - "search" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Suggestions" + + /users/{userId}/playlists/{kind}: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + get: + operationId: getPlaylistById + summary: Получение плейлиста по уникальному идентификатору + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Playlist" + + /users/{userId}/playlists: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: query + required: true + name: kinds + schema: + type: string + example: "1000,1003" + - in: query + required: true + name: mixed + schema: + type: boolean + example: false + - in: query + required: true + name: rich-tracks + schema: + type: boolean + example: false + get: + operationId: getUserPlaylistsByIds + summary: Получение плейлистов по идентификаторам + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Playlist" + + /users/{userId}/playlists/create: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + post: + operationId: createPlaylist + tags: + - "user" + - "playlists" + summary: Создание нового плейлиста + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - "title" + - "visibility" + properties: + title: + type: string + visibility: + $ref: "#/components/schemas/VisibilityEnum" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Playlist" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + 401: + description: Unauthorized + + /users/{userId}/playlists/{kind}/name: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + post: + operationId: renamePlaylist + summary: Изменение названия плейлиста. + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + value: + type: string + example: new-name + responses: + 200: + description: Изменение видимости плейлиста + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Playlist" + + /users/{userId}/playlists/{kind}/delete: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + post: + operationId: deletePlaylist + summary: Удаление плейлиста + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: string + + /users/{userId}/playlists/{kind}/change-relative: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + post: + operationId: changePlaylistTracks + summary: Добавление треков в плейлист + description: Используй '{"diff":{"op":"insert","at":0,"tracks":[{"id":"20599729","albumId":"2347459"}]}}' - для добавления, {"diff":{"op":"delete","from":0,"to":1,"tracks":[{"id":"20599729","albumId":"2347459"}]}} - для удаления треков + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + diff: + type: string + revision: + type: string + example: "0" + examples: + insert: + value: + diff: '{"diff":{"op":"insert","at":0,"tracks":[{"id":"20599729","albumId":"2347459"}]}}' + revision: "0" + remove: + value: + diff: '{"diff":{"op":"delete","from":0,"to":1,"tracks":[{"id":"20599729","albumId":"2347459"}]}}' + revision: "0" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/Playlist" + 412: + description: Wrong revision or Failed to patch current playlist + content: + application/json: + schema: + type: object + properties: + name: + type: string + message: + type: string + + /users/{userId}/playlists/{kind}/recommendations: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + get: + operationId: getRecommendations + summary: Получение рекомендаций для плейлиста + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/PlaylistRecommendations" + + /users/{userId}/playlists/{kind}/visibility: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + - in: path + required: true + name: kind + description: Идентификатор плейлиста + schema: + type: number + example: 1000 + post: + operationId: changePlaylistVisibility + summary: Изменение видимости плейлиста + description: Необходимо передать "public" или "private" в качестве значения + tags: + - "user" + - "playlists" + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + value: + $ref: "#/components/schemas/VisibilityEnum" + responses: + 200: + description: Изменение видимости плейлиста + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Playlist" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /users/{userId}/likes/tracks/add-multiple: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + post: + operationId: likeTracks + summary: Пометить треки как "Мне нравится" + tags: + - "user" + - "tracks" + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + "track-ids": + type: array + items: + type: string + example: [7019818, 29238706, 83063895] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + properties: + revision: + type: number + 401: + description: Unauthorized + content: {} + + /users/{userId}/likes/tracks/remove: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + post: + operationId: removeLikedTracks + summary: Удаление треков из списка "Мне нравится" + tags: + - "user" + - "tracks" + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + "track-ids": + type: array + items: + type: string + example: [7019818, 29238706, 83063895] + responses: + 200: + description: Ok + content: {} + 401: + description: Unauthorized + content: {} + + /users/{userId}/likes/tracks: + parameters: + - in: path + required: true + name: userId + description: Идентификатор пользователя + schema: + type: number + example: 541320800 + get: + operationId: getLikedTracksIds + summary: Получение треков с отметкой "Мне нравится" + tags: + - "user" + - "tracks" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: [library] + properties: + library: + $ref: "#/components/schemas/TracksList" + 404: + description: Not Found + content: + application/json: + schema: + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + properties: + name: + type: string + message: + type: string + + /tracks/: + post: + operationId: getTracks + summary: Получение треков + tags: + - "tracks" + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + "track-ids": + description: Уникальные идентификаторы треков + type: array + items: + type: string + example: [7019818, 29238706] + "with-positions": + description: С позициями + type: boolean + example: false + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Track" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /tracks/{trackId}/download-info: + parameters: + - in: path + required: true + name: trackId + schema: + type: string + example: "78550148" + get: + operationId: getDownloadInfo + summary: Получение информации о доступных вариантах загрузки трека. + tags: + - "tracks" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + type: object + $ref: "#/components/schemas/TrackDownloadInfo" + + /tracks/{trackId}/supplement: + parameters: + - in: path + required: true + name: trackId + schema: + type: string + example: 32326555 + get: + operationId: getTrackSupplement + description: Получение дополнительной информации о треке (Текст песни, видео, и т.д.). + summary: Получение дополнительной информации о треке (Текст песни, видео, и т.д.). + tags: + - "tracks" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/Supplement" + + /tracks/{trackId}/similar: + parameters: + - in: path + required: true + name: trackId + schema: + type: string + example: 32326555 + get: + operationId: getSimilarTracks + summary: Получение похожих треков + description: Получение похожих треков + tags: + - tracks + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/SimilarTracks" + + /tracks/{trackId}/lyrics: + parameters: + - in: path + required: true + name: trackId + schema: + type: string + - in: query + required: false + name: format + schema: + type: string + - in: query + required: false + name: timeStamp + schema: + type: string + - in: query + required: false + name: sign + schema: + type: string + get: + operationId: getTrackLyrics + summary: Получение текста песни с таймкодами + tags: + - tracks + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + # $ref: "#/components/schemas/SimilarTracks" + /play-audio: + post: + summary: Метод для отправки текущего состояния прослушиваемого трека + operationId: playAudio + tags: + - tracks + security: + - oAuth: [] + - oAuthProxied: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + required: + - from + type: object + properties: + track-id: + description: Уникальный идентификатор трека + type: string + from-cache: + type: boolean + description: Проигрывается ли трек из кеша + from: + type: string + description: Наименования клиента с которого происходит прослушивание + play-id: + type: string + description: Уникальный идентификатор проигрывания + uid: + type: number + description: Уникальный идентификатор пользователя + timestamp: + type: string + description: Текущая дата и время в ISO + example: "2021-03-17T18:13:40.000+00:00" + track-length-seconds: + type: number + description: Продолжительность трека в секундах + total-played-seconds: + type: number + description: Продолжительность трека в секундах + end-position-seconds: + type: number + description: Продолжительность трека в секундах + album-id: + description: Уникальный идентификатор альбома + type: string + playlist-id: + type: string + description: Уникальный идентификатор проигрывания + client-now: + type: string + description: Текущая дата и время клиента в ISO + example: "2021-03-17T18:13:40.000+00:00" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + description: Возвращает строку "ok" при успешном выполнении запроса + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + type: string + example: ok + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /artists/{artistId}/track-ids-by-rating: + parameters: + - in: path + required: true + name: artistId + schema: + type: string + example: 218099 + get: + summary: Получение популярных треков для артиста + operationId: getPopularTracks + tags: + - "artists" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + required: [artist, tracks] + properties: + artist: + $ref: "#/components/schemas/Artist" + tracks: + type: array + items: + type: string + + /artists/{artistId}/brief-info: + parameters: + - in: path + required: true + name: artistId + schema: + type: string + example: 218099 + get: + # TODO: add operationId + tags: + - "artists" + responses: + 200: + description: Ok + content: {} + + /artists/{artistId}/tracks: + parameters: + - in: path + required: true + name: artistId + schema: + type: string + example: 218099 + - in: query + required: false + name: page + schema: + type: number + example: 0 + - in: query + required: false + name: page-size + schema: + type: number + example: 20 + get: + operationId: getArtistTracks + tags: + - "artists" + responses: + 200: + description: Ok + content: {} + + /artists/{artistId}/direct-albums: + parameters: + - in: path + required: true + name: artistId + schema: + type: string + example: 218099 + - in: query + required: false + name: page + schema: + type: number + example: 0 + - in: query + required: false + name: page-size + schema: + type: number + example: 20 + - in: query + required: false + name: sort-by + schema: + type: string + enum: + - "year" + - "rating" + get: + # TODO: add operationId + tags: + - "artists" + responses: + 200: + description: Ok + content: {} + + /rotor/station/{stationId}/info: + parameters: + - in: path + required: true + name: stationId + description: "ID радиостанции, для примера: user:onyourwave это моя волна" + schema: + type: string + example: user:onyourwave + get: + operationId: getStationInfo + summary: "Получение информации о станции и пользовательских настроек на неё" + tags: + - "rotor" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Station" + + /rotor/station/{stationId}/tracks: + parameters: + - in: path + required: true + name: stationId + description: "ID радиостанции, для примера: user:onyourwave это моя волна" + schema: + type: string + example: user:onyourwave + - in: query + required: false + name: settings2 + description: "Использовать ли второй набор настроек. Все официальные клиенты выполняют запросы с `settings2 = True`." + schema: + type: boolean + example: true + - in: query + required: false + name: queue + description: "Уникальной идентификатор трека, который только что был." + schema: + type: string + get: + operationId: getStationTracks + summary: "Получение цепочки треков определённой станции" + tags: + - "rotor" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/StationTracksResult" + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /rotor/account/status: + get: + # TODO: add operationId + summary: "Получение статуса пользователя с дополнителными полями" + tags: + - "rotor" + - "account" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: {} + 400: + description: Bad Request + content: + application/json: + schema: + $ref: "#/components/schemas/BadRequest" + + /rotor/stations/list: + parameters: + - in: query + required: false + name: language + description: "Язык, на котором будет информация о станциях" + schema: + type: string + example: ru + get: + operationId: getStationsList + summary: "Получение всех радиостанций с настройками пользователя" + tags: + - "rotor" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/StationResult" + + /rotor/stations/dashboard: + get: + operationId: getRotorStationsDashboard + summary: "Получение рекомендованных станций текущего пользователя" + security: + - oAuth: [] + - oAuthProxied: [] + tags: + - "rotor" + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: array + items: + $ref: "#/components/schemas/Dashboard" + + /rotor/station/{stationId}/feedback: + parameters: + - in: path + required: true + name: stationId + description: Станция + schema: + type: string + example: user:onyourwave + post: + operationId: sendStationFeedback + summary: "Отправка ответной реакции на происходящее при прослушивании радио. Сообщения о начале прослушивания радио, начале и конце трека, его пропуска." + tags: + - "rotor" + security: + - oAuth: [] + - oAuthProxied: [] + parameters: + - in: query + description: Уникальный идентификатор партии треков. Возвращается при получении треков. Должен отсутствовать, для типа 'radioStarted' + name: 'batch-id' + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - "type" + properties: + type: + description: Тип отправляемого фидбека + type: string + enum: + - radioStarted + - trackStarted + - trackFinished + - skip + timestamp: + description: Текущее время и дата + type: string + example: "2023-02-26T09:41:23" + from: + description: Откуда начато воспроизведение радио + type: string + example: mobile-radio-user-onyourwave + trackId: + type: string + description: Уникальной идентификатор трека + example: 56157695:8288161 + totalPlayedSeconds: + type: number + description: Сколько было проиграно секунд трека. Необходимо указывать только для типов 'trackFinished' и 'skip' + + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + description: "ok" + type: string + + /non-music/calague: + get: + operationId: getBooksAndPodcasts + summary: "Получение блоков книг и подкастов с главной страницы" + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + type: object + $ref: "#/components/schemas/InvocationInfo" + result: + type: object + $ref: "#/components/schemas/BooksAndPodcastsResult" + + /queues: + parameters: + - in: header + required: true + name: "X-Yandex-Music-Device" + description: Содержит информацию об устройстве с которого выполняется запрос. Именно к `device` привязывается очередь. На одном устройстве может быть создана одна очередь. + schema: + type: string + example: "os=unknown; os_version=unknown; manufacturer=unknown; model=unknown; clid=; device_id=unknown; uuid=unknown" + get: + operationId: getQueues + summary: Получение всех очередей треков с разных устройств для синхронизации между ними + tags: + - queues + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/QueuesResult" + /queues/{queueId}: + parameters: + - in: path + required: true + name: queueId + description: Идентификатор очереди + schema: + type: string + get: + operationId: getQueueById + summary: Получение очереди с треками по идентификатору + tags: + - queues + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/QueueItem" + + /queues/{queueId}/update-position: + parameters: + - in: path + required: true + name: queueId + description: Идентификатор очереди + schema: + type: string + - in: query + required: true + name: currentIndex + description: Текущий индекс + schema: + type: string + - in: query + required: true + name: IsInteractive + schema: + type: boolean + example: True + post: + operationId: updateQueuePosition + summary: Установка текущего индекса проигрываемого трека в очереди треков + tags: + - queues + security: + - oAuth: [] + - oAuthProxied: [] + responses: + 200: + description: Ok + content: + application/json: + schema: + required: + - "invocationInfo" + - "result" + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + result: + $ref: "#/components/schemas/UpdateQueueResult" + +components: + securitySchemes: + oAuthProxied: + type: oauth2 + description: Authentication via proxy server to bypass cors + flows: + password: + tokenUrl: https://yandex-music-cors-proxy.onrender.com/https://oauth.yandex.ru/token + scopes: {} + oAuth: + type: oauth2 + description: This API uses OAuth 2 with the implicit grant flow. [More info](https://api.example.com/docs/auth) + flows: + password: + tokenUrl: https://oauth.yandex.ru/token + scopes: {} + schemas: + UserSettings: + type: object + properties: + uid: + type: number + lastFmScrobblingEnabled: + type: boolean + example: false + facebookScrobblingEnabled: + type: boolean + example: false + shuffleEnabled: + type: boolean + example: false + addNewTrackOnPlaylistTop: + type: boolean + example: false + volumePercents: + type: boolean + example: 75 + userMusicVisibility: + $ref: "#/components/schemas/VisibilityEnum" + userSocialVisibility: + $ref: "#/components/schemas/VisibilityEnum" + adsDisabled: + type: boolean + example: true + modified: + type: string + example: "2019-04-14T14:55:50+00:00" + rbtDisabled: + type: string + theme: + type: string + enum: + - "black" + - "default" + description: Тема оформления. + example: "black" + promosDisabled: + type: boolean + example: true + autoPlayRadio: + type: boolean + example: true + syncQueueEnabled: + type: boolean + example: false + VisibilityEnum: + type: string + enum: + - "private" + - "public" + example: "public" + InvocationInfo: + type: object + required: + - "exec-duration-millis" + - "hostname" + - "req-id" + properties: + "exec-duration-millis": + type: number + hostname: + type: string + "req-id": + type: string + "app-name": + example: music-play-queue + type: string + Error: + type: object + properties: + name: + type: string + example: "validate" + message: + type: string + example: "Parameters requirements are not met" + Account: + required: + - 'now' + - 'uid' + - 'login' + - 'region' + - 'fullName' + - 'secondName' + - 'fisrtName' + - 'displayName' + - 'serviceAvailable' + - 'hostedUser' + - 'passport-phones' + type: object + description: Основная информация об аккаунте + properties: + now: + type: string + description: Текущая дата и время + example: "2021-03-17T18:13:40+00:00" + uid: + type: number + description: Уникальный идентификатор + login: + type: string + description: Виртуальное имя (обычно e-mail) + region: + type: number + description: Регион + fullName: + type: string + description: Полное имя (имя и фамилия) + secondName: + type: string + description: Фамилия + firstName: + type: string + description: Имя + displayName: + type: string + description: Отображаемое имя + serviceAvailable: + type: boolean + description: Доступен ли сервис + hostedUser: + type: boolean + description: Является ли пользователем чьим-то другим + "passport-phones": + type: array + description: Мобильные номера + items: + type: object + properties: + phone: + type: string + Album: + type: object + required: + - "id" + - "title" + - "type" + - "metaType" + - "year" + - "releaseDate" + - "coverUri" + - "ogImage" + - "genre" + - "buy" + - "trackCount" + - "recent" + - "veryImportant" + - "artists" + - "labels" + - "available" + - "availableForPremiumUsers" + - "availableForMobile" + - "availablePartially" + - "bests" + - "prerolls" + - "volumes" + properties: + id: + type: number + description: Идентификатор альбома + error: + description: Ошибка получения альбома + type: string + nullable: true + title: + description: Название альбома + type: string + "type": + description: Тип альбома + type: string + enum: ["single", "podcast"] + metaType: + description: Мета тип + type: string + enum: ["single", "podcast", "music"] + year: + description: Год релиза + type: number + releaseDate: + description: Дата релиза в формате ISO 8601 + type: string + coverUri: + description: Ссылка на обложку + type: string + ogImage: + description: Ссылка на превью Open Graph + type: string + genre: + description: Жанр музыки + type: string + buy: + type: array + items: + type: object + trackCount: + description: Количество треков. + type: number + recent: + description: Является ли альбом новым + type: boolean + veryImportant: + description: Популярен ли альбом у слушателей + type: boolean + artists: + description: Артисты + type: array + items: + type: object + $ref: "#/components/schemas/Artist" + labels: + description: Лейблы + type: array + items: + type: object + properties: + id: + type: number + name: + type: string + available: + description: Доступен ли альбом + type: boolean + availableForPremiumUsers: + description: Доступен ли альбом для пользователей с подпиской + type: boolean + availableForMobile: + description: Доступен ли альбом из приложения для телефона + type: boolean + availablePartially: + description: Доступен ли альбом частично для пользователей без подписки + type: boolean + bests: + description: ID лучших треков альбома + type: array + items: + type: number + prerolls: + description: Прероллы + type: array + items: + type: object + volumes: + description: Треки альбома, разделенные по дискам. + type: array + nullable: true + items: + type: array + items: + type: object + $ref: "#/components/schemas/Track" + Artist: + type: object + required: + - "id" + - "composer" + - "genres" + - "name" + - "various" + properties: + composer: + type: boolean + cover: + type: object + nullable: true + $ref: "#/components/schemas/Cover" + decomposed: + type: array + nullable: true + items: + type: object + genres: + type: array + items: + type: object + id: + oneOf: + - type: string + - type: number + name: + type: string + various: + type: boolean + popularTracks: + type: array + nullable: true + items: + type: object + $ref: "#/components/schemas/Track" + ticketsAvailable: + description: Имеются ли в продаже билеты на концерт + nullable: true + type: boolean + regions: + type: array + nullable: true + items: + type: string + Cover: + type: object + required: [custom, type] + properties: + custom: + type: boolean + dir: + description: Существует когда поле type = "pic" + type: string + type: + type: string + enum: ["pic", "mosaic"] + itemsUri: + description: Существует когда поле type = "mosaic" + type: array + items: + type: string + uri: + description: Существует когда поле type = "pic" + type: string + version: + type: string + error: + type: string + description: If exists other properties is missing + Owner: + type: object + required: [login, name, sex, uid, verified] + properties: + login: + type: string + name: + type: string + sex: + type: string + uid: + type: number + verified: + type: boolean + Track: + type: object + required: + - "albums" + - "artists" + - "available" + - "availableForPremiumUsers" + - "availableFullWithoutPermission" + - "coverUri" + - "durationMs" + - "fileSize" + - "id" + - "lyricsAvailable" + - "major" + - "normalization" + - "ogImage" + - "previewDurationMs" + - "realId" + - "rememberPosition" + - "storageDir" + - "title" + - "type" + properties: + albums: + type: array + items: + type: object + $ref: "#/components/schemas/Album" + artists: + type: array + items: + type: object + $ref: "#/components/schemas/Artist" + available: + type: boolean + availableForPremiumUsers: + type: boolean + availableFullWithoutPermission: + type: boolean + coverUri: + description: Cover uri template + type: string + durationMs: + type: number + fileSize: + type: number + id: + type: string + lyricsAvailable: + type: boolean + major: + type: object + required: [id, name] + properties: + id: + type: number + name: + type: string + normalization: + type: object + required: [gain, peak] + properties: + gain: + type: number + peak: + type: number + ogImage: + type: string + previewDurationMs: + type: number + realId: + type: string + rememberPosition: + type: boolean + storageDir: + type: string + title: + type: string + type: + type: string + TrackItem: + required: [id, playCount, recent, timestamp] + type: object + properties: + id: + type: number + playCount: + type: number + recent: + type: boolean + timestamp: + type: string + track: + description: Null when tracks are not riched + type: object + $ref: "#/components/schemas/Track" + Playlist: + description: play list data + required: + - "playlistUuid" + - "description" + - "descriptionFormatted" + - "available" + - "collective" + - "created" + - "modified" + - "backgroundColor" + - "textColor" + - "durationMs" + - "isBanner" + - "isPremiere" + - "kind" + - "owner" + - "revision" + - "snapshot" + - "tags" + - "title" + - "trackCount" + - "uid" + - "visibility" + - "likesCount" + - "tracks" + type: object + properties: + playlistUuid: + type: string + description: + type: string + descriptionFormatted: + type: string + available: + type: boolean + collective: + type: boolean + cover: + type: object + $ref: "#/components/schemas/Cover" + created: + type: string + modified: + type: string + backgroundColor: + type: string + textColor: + type: string + durationMs: + type: number + isBanner: + type: boolean + isPremiere: + type: boolean + kind: + type: number + ogImage: + type: string + owner: + type: object + $ref: "#/components/schemas/Owner" + prerolls: + type: array + items: + type: object + revision: + type: number + snapshot: + type: number + tags: + type: array + items: + type: object + properties: + id: + type: string + value: + type: string + title: + type: string + trackCount: + type: number + uid: + type: number + visibility: + type: string + enum: ["public", "private"] + likesCount: + type: number + tracks: + type: array + items: + type: object + $ref: "#/components/schemas/TrackItem" + Status: + required: + - "account" + - "permissions" + - "subscription" + - "subeditor" + - "subeditorLevel" + - "pretrialActive" + - "plus" + - "defaultEmail" + - "userhash" + type: object + properties: + account: + type: object + $ref: "#/components/schemas/Account" + permissions: + type: object + $ref: "#/components/schemas/Permissions" + subscription: + type: object + $ref: "#/components/schemas/Subscription" + subeditor: + type: boolean + description: Наличие статуса модератора проверки корректности информации + subeditorLevel: + type: number + description: Уровень статуса модератора + pretrialActive: + type: boolean + plus: + type: object + description: Информация о Plus подписке + properties: + hasPlus: + type: boolean + isTutorialCompleted: + type: boolean + migrated: + type: boolean + defaultEmail: + type: string + description: Основной e-mail адрес аккаунта + example: "email@example.com" + barBelow: + type: object + description: Блок с предупреждениями о конце подписки и подарках + $ref: "#/components/schemas/Alert" + userhash: + type: string + Permissions: + type: object + description: Информация о правах пользователя, их изначальных значениях и даты окончания + properties: + until: + type: string + example: "2021-03-24T18:13:40+00:00" + values: + type: array + items: + enum: + [ + "landing-play", + "feed-play", + "radio-play", + "mix-play", + "radio-skips", + "play-radio-full-tracks", + ] + default: + type: array + items: + enum: + [ + "landing-play", + "feed-play", + "radio-play", + "mix-play", + "radio-skips", + "play-radio-full-tracks", + ] + Subscription: + type: object + description: Информация о подписках пользователя + properties: + hadAnySubscription: + type: boolean + canStartTrial: + type: boolean + mcdonalds: + type: boolean + Alert: + type: object + description: Блок с предупреждениями о конце подписки и подарках + properties: + alertId: + type: string + description: Уникальный идентификатор + text: + type: string + description: Текст предупреждения + bgColor: + type: string + description: Цвет заднего фона (HTML) + textColor: + type: string + description: Цвет текста (HTML) + alertType: + type: string + description: Тип предупреждения + example: "Promo_rk360" + button: + type: object + $ref: "#/components/schemas/AlertButton" + closeButton: + type: boolean + description: Наличие кнопки "Закрыть" + AlertButton: + type: object + description: кнопка со ссылкой + properties: + text: + type: string + description: Текст на кнопке + bgColor: + type: string + description: Цвет заднего фона (HTML) + textColor: + type: string + description: Цвет текста (HTML) + uri: + type: string + description: Ссылка, куда ведет кнопка + Suggestions: + type: object + description: подсказки при поиске + properties: + best: + type: object + suggestions: + type: array + items: + type: string + Best: + type: object + description: лучший результат поиска + properties: + type: + type: string + description: Тип лучшего результата + text: + type: string + result: + oneOf: + - $ref: "#/components/schemas/Track" + - $ref: "#/components/schemas/Artist" + - $ref: "#/components/schemas/Album" + - $ref: "#/components/schemas/Playlist" + - $ref: "#/components/schemas/Video" + PlaylistRecommendations: + type: object + description: рекомендации для плейлиста + properties: + batch_id: + type: string + description: Уникальный идентификатор партии треков + tracks: + type: array + items: + type: object + $ref: "#/components/schemas/Track" + Settings: + type: object + description: Предложения по покупке + properties: + inAppProducts: + type: array + items: + type: object + $ref: "#/components/schemas/Product" + nativeProducts: + type: array + items: + type: object + $ref: "#/components/schemas/Product" + webPaymentUrl: + type: string + webPaymentMonthProductPrice: + type: object + $ref: "#/components/schemas/Price" + promoCodesEnabled: + type: boolean + Price: + type: object + description: цена + properties: + amount: + description: Количество единиц + type: number + currency: + description: Валюта + type: string + Product: + type: object + description: продаваемый продукт + properties: + productId: + description: Уникальный идентификатор. + type: string + type: + description: Тип продаваемого. + type: string + commonPeriodDuration: + description: Длительность общего периода. + type: string + duration: + description: Длительность. + type: number + trialDuration: + description: Длительность испытательного срока. + type: number + price: + description: Цена. + type: object + $ref: "#/components/schemas/Price" + feature: + description: Предоставляемая возможность. + type: string + debug: + description: Отладочный продукт. + type: boolean + plus: + description: Даёт ли подписку "Плюс". + type: boolean + cheapest: + description: Самый дешёвый (лучшее предложение). + type: boolean + title: + description: Заголовок продукта. + type: string + familySub: + description: Семейная ли подписка. + type: boolean + fbImage: + description: Картинка для превью на facebook. + type: string + fbName: + description: Заголовок превью на facebook. + type: string + family: + description: Доступно ли для семьи. + type: boolean + features: + description: Список предоставляемых возможностей. + type: array + items: + type: string + description: + description: Описание. + type: string + available: + description: Доступна ли покупка. + type: boolean + trialAvailable: + description: Доступен ли пробный период. + type: boolean + trialPeriodDuration: + description: Длительность пробного периода. + type: string + introPeriodDuration: + description: Длительность вступительного периода TODO. + type: string + introPrice: + description: Цена вступительного периода. + type: object + $ref: "#/components/schemas/Price" + startPeriodDuration: + description: Длительность первого срока (за меньшую цену). + type: string + startPrice: + description: Цена за первый срок. + type: object + $ref: "#/components/schemas/Price" + licenceTextParts: + description: Длительность пробного периода. + type: object + $ref: "#/components/schemas/LicenceTextPart" + vendorTrialAvailable: + description: Доступен испытательный срок продавца TODO. + type: boolean + buttonText: + description: Текст кнопки. + type: string + buttonAdditionalText: + description: Дополнительный текст кнопки. + type: string + paymentMethodTypes: + description: Способы оплаты. + type: array + items: + type: string + LicenceTextPart: + type: object + description: часть текста с ссылкой на лицензионное соглашение + properties: + text: + description: Часть текста. + type: string + url: + description: Ссылка на лицензионное соглашение. + type: string + PermissionAlerts: + type: object + description: оповещения + properties: + alerts: + type: array + items: + type: string + Experiments: + type: object + description: режимы экспериментальных функций + PromoCodeStatus: + type: object + description: статус активации промо-кода + properties: + status: + type: string + description: Статус операции + example: code-not-exists + statusDesc: + type: string + description: Описание статуса + example: Gift code does not exist + accountStatus: + type: object + description: Информация об аккаунте пользователя. + $ref: "#/components/schemas/Status" + Supplement: + type: object + description: дополнительная информация о треке + properties: + id: + type: number + description: Уникальный идентификатор дополнительной информации. + lyrics: + type: object + description: Текст песни. + $ref: "#/components/schemas/Lyrics" + videos: + type: object + description: Видео. + $ref: "#/components/schemas/VideoSupplement" + radioIsAvailable: + type: boolean + description: Доступно ли радио. + description: + type: string + description: Полное описание эпизода подкаста. + Lyrics: + type: object + description: текст трека + properties: + id: + type: number + description: Уникальный идентификатор текста трека. + lyrics: + type: string + description: Первые строки текст песни. + hasRights: + type: boolean + description: Есть ли права. + fullLyrics: + type: string + description: Текст песни. + textLanguage: + type: string + description: Язык текста. + showTranslation: + type: boolean + description: Доступен ли перевод. + url: + type: string + description: Ссылка на источник перевода. Обычно genius.com. + VideoSupplement: + type: object + description: видеоклипы + properties: + cover: + type: string + description: URL на обложку видео. + provider: + type: string + description: Сервис поставляющий видео. + title: + type: string + description: Название видео. + providerVideoId: + type: string + description: Уникальный идентификатор видео на сервисе. + url: + type: string + description: URL на видео. + embedUrl: + type: string + description: URL на видео, находящегося на серверах Яндекса. + embed: + type: string + description: HTML тег для встраивания видео. + Video: + type: object + description: Видео + properties: + title: + type: string + description: Название видео + cover: + type: string + description: Ссылка на изображение + embedUrl: + type: string + description: Ссылка на видео + provider: + type: string + description: Сервис поставляющий видео + providerVideoId: + type: string + description: Уникальный идентификатор видео на сервисе. + youtubeUrl: + type: string + description: Ссылка на видео Youtube + thumbnailUrl: + type: string + description: Ссылка на изображение + duration: + type: number + description: Длительность видео в секундах + text: + type: string + description: Текст + htmlAutoPlayVideoPlayer: + type: string + description: HTML тег для встраивания в разметку страницы + regions: + type: array + items: + type: string + example: ["RUSSIA_PREMIUM", "RUSSIA"] + TagResult: + type: object + description: Класс, представляющий тег и его плейлисты + properties: + tag: + $ref: "#/components/schemas/Tag" + ids: + type: array + items: + $ref: "#/components/schemas/PlaylistId" + Tag: + type: object + description: Тег + required: + - id + - value + - name + - ogDescription + properties: + id: + type: string + description: Уникальный идентификатор тега + value: + type: string + description: Значение тега (название в lower case) + name: + type: string + description: Название тега (отображаемое) + ogDescription: + type: string + description: Описание тега для OpenGraph + ogImage: + type: string + description: Ссылка на изображение для OpenGraph + PlaylistId: + type: object + properties: + uid: + type: integer + description: Уникальный идентификатор пользователя владеющим плейлистом + kind: + type: integer + description: Уникальный идентификатор плейлиста + SimilarTracks: + type: object + description: список похожих треков на другой трек + properties: + track: + type: object + description: трек + $ref: "#/components/schemas/Track" + similarTracks: + type: array + description: Похожие треки + items: + $ref: "#/components/schemas/Track" + TracksList: + type: object + description: список треков + required: [uid, revisions, tracks] + properties: + uid: + type: number + description: Уникальный идентификатор пользователя + revisions: + type: number + description: Актуальность данных TODO + tracks: + type: array + description: Список треков в укороченной версии + items: + $ref: "#/components/schemas/TrackShort" + TrackShort: + type: object + description: Укороченная версия трека с неполными данными + required: [id, albumId, timestamp] + properties: + id: + type: string + description: Уникальный идентификатор трека + albumId: + type: string + description: Уникальный идентификатор альбома + timestamp: + type: string + description: Дата + TrackDownloadInfo: + type: object + description: информация о вариантах загрузки трека + required: [codec, gain, preview, downloadInfoUrl, direct, bitrateInKbps] + properties: + codec: + type: string + enum: [mp3, aac] + description: Кодек аудиофайла + gain: + type: boolean + description: Усиление + preview: + type: string + description: Предварительный просмотр + downloadInfoUrl: + type: string + description: Ссылка на XML документ содержащий данные для загрузки трека + direct: + type: boolean + description: Прямая ли ссылка + bitrateInKbps: + type: number + description: Битрейт аудиофайла в кбит/с + Search: + type: object + required: + - "albums" + - "artists" + - "best" + - "playlists" + - "searchRequestId" + - "misspellCorrected" + - "nocorrect" + - "text" + - "podcast_episodes" + - "tracks" + description: Результаты поиска + properties: + searchResultId: + type: string + description: ID запроса + text: + type: string + description: Текст запроса + best: + $ref: "#/components/schemas/Best" + albums: + description: Найденные альбомы + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/Album" + artists: + description: Найденные артисты + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/Artist" + playlists: + description: Найденные альбомы + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/Playlist" + tracks: + description: Найденные треки + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/Track" + videos: + description: Найденные видео + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + $ref: "#/components/schemas/Video" + podcasts: + description: Найденные подкасты + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + type: object + podcast_episodes: + description: Найденные выпуски подкастов + allOf: + - $ref: "#/components/schemas/SearchResult" + - type: object + required: [results] + properties: + results: + type: array + items: + type: object + type: + $ref: "#/components/schemas/SearchType" + description: Тип результата по которому искали. Доступно, при использовании параметра type. + page: + type: number + description: Текущая страница. Доступно, при использовании параметра type. + perPage: + type: number + description: Результатов на странице. Доступно, при использовании параметра type. + misspellCorrected: + type: boolean + description: Был ли исправлен запрос + misspellOriginal: + type: string + description: Оригинальный запрос + nocorrect: + type: boolean + description: Было ли отключено исправление результата + SearchType: + type: string + enum: ["artist", "album", "track", "podcast", "all"] + default: "all" + SearchResult: + type: object + required: + - "type" + - "total" + - "perPage" + - "order" + properties: + type: + type: string + description: Тип результата + total: + type: number + description: Количество результатов + perPage: + type: number + description: Максимальное количество результатов на странице. + order: + type: number + description: Позиция блока + BadRequest: + type: object + properties: + invocationInfo: + $ref: "#/components/schemas/InvocationInfo" + error: + $ref: "#/components/schemas/Error" + LandingResult: + type: object + required: [pumpkin, contentId, blocks] + properties: + pumpkin: + type: boolean + contentId: + type: string + blocks: + type: array + items: + $ref: "#/components/schemas/LandingBlock" + LandingBlock: + type: object + required: + - "id" + - "title" + - "type" + - "typeForFrom" + - "description" + - "entities" + properties: + id: + type: string + title: + type: string + type: + description: the follwing values are allowed or combination of these values separated by comman - personal-playlists, podcasts, play-context, chart, new-playlists, new-releases, promotions + type: string + typeForFrom: + $ref: "#/components/schemas/LandingBlockType" + description: + type: string + entities: + type: array + items: + oneOf: + - $ref: "#/components/schemas/LandingPodcastItem" + - $ref: "#/components/schemas/LandingBlockItem" + LandingBlockItem: + type: object + required: [id, type, data] + properties: + id: + type: string + type: + $ref: "#/components/schemas/LandingBlockType" + data: + type: object + oneOf: + - $ref: "#/components/schemas/Album" + - $ref: "#/components/schemas/Playlist" + - $ref: "#/components/schemas/Promotion" + - $ref: "#/components/schemas/MixLink" + - $ref: "#/components/schemas/GeneratedPlaylistLandingBlock" + - $ref: "#/components/schemas/ChartItem" + LandingBlockType: + type: string + enum: + - "personalplaylists" + - "promotions" + - "new-releases" + - "new-playlists" + - "mixes" + - "chart" + - "artists" + - "albums" + - "playlists" + - "play_contexts" + - "podcasts" + LandingPodcastItem: + type: object + required: + - "type" + - "description" + - "descriptionFormatted" + - "lastUpdated" + - "data" + properties: + type: + type: string + description: + type: string + descriptionFormatted: + type: string + lastUpdated: + type: string + data: + type: object + required: + - "podcast" + - "description" + - "descriptionFormatted" + - "lastUpdated" + properties: + podcast: + type: object + $ref: "#/components/schemas/Album" + description: + type: string + descriptionFormatted: + type: string + lastUpdated: + type: string + FullChartResult: + type: object + required: + - "id" + - "type" + - "typeForFrom" + - "title" + - "chartDescription" + - "menu" + - "chart" + properties: + id: + type: string + type: + type: string + enum: + - "chart" + typeForFrom: + type: string + title: + type: string + chartDescription: + type: string + menu: + required: + - "menu" + type: object + properties: + menu: + type: object + $ref: "#/components/schemas/MenuItem" + chart: + type: object + $ref: "#/components/schemas/Playlist" + MenuItem: + type: object + properties: + title: + type: string + url: + type: string + selected: + type: boolean + NewPlaylistItem: + type: object + required: + - "uid" + - "kind" + properties: + uid: + description: id пользователя + type: number + kind: + description: id плейлиста + type: number + GeneratedPlaylist: + description: Плейлист дня, дежавю, премьера, ... + allOf: + - $ref: "#/components/schemas/Playlist" + - type: object + properties: + animatedCoverUri: + description: Доступно для плейлиста дня + type: string + coverWithoutText: + $ref: "#/components/schemas/Cover" + everPlayed: + type: boolean + generatedPlaylistType: + type: string + idForFrom: + type: string + madeFor: + type: object + playCounter: + type: object + required: [description, descriptionNext, updated, value] + properties: + description: + type: string + descriptionNext: + type: string + updated: + type: boolean + value: + type: number + GeneratedPlaylistLandingBlock: + type: object + required: [notify, ready, type, data] + properties: + notify: + type: boolean + ready: + type: boolean + type: + type: string + enum: [playlistOfTheDay] + data: + $ref: "#/components/schemas/GeneratedPlaylist" + Chart: + type: object + required: [bgColor, position, progress, listeners, shift] + properties: + bgColor: + type: string + position: + type: number + progress: + type: string + enum: [same, up, down] + listeners: + type: number + shift: + type: number + Promotion: + type: object + required: + - "promoId" + - "title" + - "subtitle" + - "heading" + - "urlScheme" + - "url" + - "textColor" + - "gradient" + - "image" + properties: + promoId: + type: string + title: + type: string + subtitle: + type: string + heading: + type: string + urlScheme: + type: string + url: + type: string + textColor: + type: string + gradient: + type: string + image: + type: string + MixLink: + type: object + required: + - "title" + - "url" + - "urlScheme" + - "textColor" + - "backgroundColor" + - "coverWhite" + properties: + title: + type: string + url: + type: string + urlScheme: + type: string + textColor: + type: string + backgroundColor: + type: string + coverWhite: + type: string + ChartItem: + required: + - "track" + - "chart" + allOf: + - $ref: "#/components/schemas/TrackItem" + - type: object + properties: + # track: + # $ref: '#/components/schemas/Track' + chart: + $ref: "#/components/schemas/Chart" + StationTracksResult: + type: object + required: + - "id" + - "sequence" + - "batchId" + - "pumpkin" + - "radioSessionId" + properties: + id: + type: object + required: + - type + - tag + properties: + type: + type: string + tag: + type: string + sequence: + type: array + items: + $ref: "#/components/schemas/SequenceItem" + batchId: + type: string + pumpkin: + type: boolean + radioSessionId: + type: string + SequenceItem: + type: object + description: Класс, представляющий звено последовательности радио станции + required: + - "type" + - "track" + - "liked" + - "trackParameters" + properties: + type: + type: string + track: + $ref: "#/components/schemas/Track" + liked: + type: boolean + trackParameters: + type: object + required: + - "bpm" + - "hue" + - "energy" + properties: + bpm: + type: number + hue: + type: number + energy: + type: number + BooksAndPodcastsResult: + type: object + description: Блоки главной страницы кнги и подкастов + required: + - "title" + - "blocks" + properties: + title: + type: string + blocks: + type: array + items: + type: object + required: + - "id" + - "type" + - "typeForFrom" + properties: + id: + type: string + title: + type: string + type: + type: string + typeForFrom: + type: string + entities: + type: array + items: + type: object + properties: + id: + type: string + type: + type: string + # TODO: complete "data" type + data: + type: object + StationResult: + type: object + properties: + station: + $ref: "#/components/schemas/Station" + data: + $ref: "#/components/schemas/RotorData" + settings: + $ref: "#/components/schemas/RotorSettings" + settings2: + $ref: "#/components/schemas/RotorSettings2" + adParams: + $ref: "#/components/schemas/AdParams" + rupTitle: + type: string + rupDescription: + type: string + Station: + type: object + required: + - "id" + - "name" + - "icon" + - "mtsIcon" + properties: + id: + $ref: "#/components/schemas/StationId" + description: Уникальный идентификатор станции + parentId: + $ref: "#/components/schemas/StationId" + description: Уникальный идентификатор станции, являющейся предком текущей + name: + type: string + description: Название станции + icon: + $ref: "#/components/schemas/Icon" + description: Иконка станции + mtsIcon: + $ref: "#/components/schemas/Icon" + description: Иконка станции + fullImageUrl: + type: string + description: Ссылка на полное изображение + mtsFullImageUrl: + type: string + description: Ссылка на полную иконку + idForFrom: + type: string + description: Категория (тип) станции + restrictions: + $ref: "#/components/schemas/Restrictions" + description: Ограничения для настроек станции старого формата + restrictions2: + $ref: "#/components/schemas/Restrictions2" + description: Ограничения для настроек станции + RotorSettings: + type: object + required: + - "language" + - "diversity" + - "mood" + - "energy" + properties: + language: + type: string + diversity: + type: string + mood: + type: number + energy: + type: number + RotorSettings2: + type: object + required: + - "language" + - "diversity" + - "moodEnergy" + properties: + language: + type: string + diversity: + type: string + moodEnergy: + type: string + AdParams: + type: object + required: + - "partnerId" + - "categoryId" + - "pageRef" + - "targetRef" + - "otherParams" + properties: + partnerId: + type: string + categoryId: + type: string + pageRef: + type: string + targetRef: + type: string + otherParams: + type: string + adVolume: + type: number + genreId: + type: number + genreName: + type: string + RotorData: + type: object + required: + - "title" + - "description" + - "imageUrl" + - "artists" + properties: + title: + type: string + description: + type: string + imageUrl: + type: string + artists: + type: array + items: + $ref: "#/components/schemas/Artist" + Icon: + type: object + required: + - "backgroundColor" + - "imageUrl" + properties: + backgroundColor: + type: string + imageUrl: + type: string + StationId: + required: + - "type" + - "tag" + properties: + type: + type: string + tag: + type: string + Restrictions: + type: object + description: Ограничения для настроек станции старого формата + properties: + language: + $ref: "#/components/schemas/RestrictionEnum" + mood: + $ref: "#/components/schemas/DiscreteScale" + energy: + $ref: "#/components/schemas/DiscreteScale" + diversity: + $ref: "#/components/schemas/RestrictionEnum" + Restrictions2: + type: object + description: Ограничения для настроек станции + properties: + diversity: + $ref: "#/components/schemas/RestrictionEnum2" + moodEnergy: + $ref: "#/components/schemas/RestrictionEnum2" + language: + $ref: "#/components/schemas/RestrictionEnum2" + DiscreteScale: + type: object + description: Класс, представляющий дискретное значение + properties: + type: + type: string + name: + type: string + min: + $ref: "#/components/schemas/MinMax" + max: + $ref: "#/components/schemas/MinMax" + MinMax: + type: object + required: + - "value" + - "name" + properties: + value: + type: number + name: + type: string + RestrictionEnum: + required: + - "type" + - "name" + - "possibleValues" + properties: + type: + type: string + name: + type: string + possibleValues: + type: object + required: + - "name" + - "value" + properties: + name: + type: string + value: + type: string + RestrictionEnum2: + type: object + required: + - "type" + - "name" + - "possibleValues" + properties: + type: + type: string + name: + type: string + possibleValues: + type: object + required: + - "name" + - "value" + - "imageUrl" + - "serializedSeed" + properties: + name: + type: string + value: + type: string + imageUrl: + type: string + serializedSeed: + type: string + Dashboard: + type: object + required: + - "dashboardId" + - "stations" + - "pumpkin" + properties: + dashboardId: + type: string + stations: + type: array + items: + $ref: "#/components/schemas/StationResult" + pumpkin: + type: boolean + Genre: + type: object + required: + - "id" + - "weight" + - "composerTop" + - "title" + - "titles" + - "images" + - "showInMenu" + properties: + id: + description: Уникальный идентификатор жанра + type: string + weight: + description: Вес TODO (возможно, чем выше показатель, тем больше нравится пользователю) + type: number + composerTop: + type: boolean + title: + description: Заголовок жанра + type: string + titles: + description: Словарь заголовков на разных языках, где ключ - язык + type: object + additionalProperties: + type: object + required: + - "title" + - "fullTitle" + properties: + title: + type: string + fullTitle: + type: string + images: + description: Изображение жанра + type: object + additionalProperties: + type: string + example: + "208x208": "http://avatars.mds.yandex.net/get-music-misc/29541/metagenre-other-x208/orig" + "300x300": "http://avatars.mds.yandex.net/get-music-misc/49997/metagenre-other-x300/orig" + showInMenu: + description: Показывать в меню + type: boolean + showInRegions: + description: Список регионов в которых отображается жанр в списках + type: array + items: + type: number + fullTitle: + description: Полный заголовок + type: string + urlPart: + description: Часть ссылки на жанр для открытия в браузере + type: string + color: + description: Цвет фона изображения + type: string + radioIcon: + description: Иконка радио жанра + $ref: "#/components/schemas/Icon" + subGenres: + description: Поджанры текущего жанра музыки + $ref: "#/components/schemas/Genre" + hideInRegions: + description: В каких регионах скрывать жанр + type: array + items: + type: number + QueuesResult: + type: object + required: + - "queues" + properties: + queues: + type: array + items: + $ref: "#/components/schemas/QueueItem" + QueueItem: + type: object + description: Очередь треков в списке очередей устройств + required: + - "id" + - "context" + - "modified" + properties: + id: + description: Уникальный идентификатор очереди + type: string + context: + $ref: "#/components/schemas/QueueContext" + modified: + type: string + tracks: + description: Поле присутствует, только при запросе очереди по идентификатору + type: array + items: + $ref: "#/components/schemas/QueueTrack" + currentIndex: + description: Поле присутствует, только при запросе очереди по идентификатору + type: number + QueueContext: + type: object + description: Содержимое очереди, на основе чего она построена (плейлист, радио, ...) + required: + - "type" + properties: + id: + description: Уникальный идентификатор типа содержимого (плейлиста, альбома и т.д.). При `type` равным `my_music` или `various` поле `id` отсутствует. + type: string + description: + description: Описание содержимого (например, название плейлиста, радиостанции) - `various`, `my_music`, `radio`, `playlist`, `artist`. Тип `various` используется при прослушивании из раздела "Моя музыка" с сайта, а `my_music` с мобильных клиентов. + type: string + type: + description: Тип содержимого, на основе чего построена очередь. Значение есть зачастую только когда `type` имеет значение `my_music` или `various`. + type: string + example: "radio" + QueueTrack: + type: object + required: + - "trackId" + - "albumId" + - "from" + properties: + trackId: + type: string + albumId: + type: string + from: + type: string + UpdateQueueResult: + type: object + required: + - "status" + - "mostRecentQueue" + properties: + status: + type: string + mostRecentQueue: + type: string diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..66f464f --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module ym-dl + +go 1.27.2 + +require ( + gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea + github.com/bogem/id3v2 v1.2.0 + github.com/mattn/go-sqlite3 v1.14.24 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/gorilla/websocket v1.5.3 // indirect + golang.org/x/text v0.32.0 // indirect + google.golang.org/protobuf v1.36.12 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..49427c2 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea h1:JOsFlBq/AdrAeyus0+KNSiISXodjyejRb6z5VOgk+n8= +gitea.mrixs.me/Mrixs/yandex-music-api-go v0.0.0-20261014200451-02a6dabcfbea/go.mod h1:L+GcqPRZiu0Srq6+YxfdLztltXV8DcxKjNUOXWJvR9o= +github.com/bogem/id3v2 v1.2.0 h1:hKDF+F1gOgQ5r1QmBCEZUk4MveJbKxCeIDSBU7CQ4oI= +github.com/bogem/id3v2 v1.2.0/go.mod h1:t78PK5AQ56Q47kizpYiV6gtjj3jfxlz87oFpty8DYs8= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..fafe8b4 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,92 @@ +package config + +import ( + "errors" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +// Config holds the application configuration +type Config struct { + Yandex YandexConfig `yaml:"yandex"` + Artists []Artist `yaml:"artists"` + Storage StorageConfig `yaml:"storage"` + Monitor MonitorConfig `yaml:"monitor"` + Debug bool `yaml:"debug"` +} + +// YandexConfig holds Yandex Music API credentials +type YandexConfig struct { + Token string `yaml:"token"` + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// Artist represents an artist to monitor +type Artist struct { + ID int `yaml:"id"` +} + +// StorageConfig holds storage settings +type StorageConfig struct { + Path string `yaml:"path"` + Organize bool `yaml:"organize"` +} + +// MonitorConfig holds monitor settings +type MonitorConfig struct { + Interval string `yaml:"interval"` +} + +// Load loads configuration from a YAML file +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, err + } + + if err := cfg.Validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +// Validate validates the configuration +func (c *Config) Validate() error { + if len(c.Artists) == 0 { + return errors.New("at least one artist must be configured") + } + + for _, a := range c.Artists { + if a.ID <= 0 { + return errors.New("artist ID must be positive") + } + } + + if c.Storage.Path == "" { + return errors.New("storage path is required") + } + + if c.Yandex.Token == "" && (c.Yandex.Username == "" || c.Yandex.Password == "") { + return errors.New("either yandex.token or yandex.username/password must be provided") + } + + if _, err := time.ParseDuration(c.Monitor.Interval); err != nil { + return errors.New("monitor.interval must be a valid duration (e.g., 24h, 1h, 7d)") + } + + return nil +} + +// GetMonitorInterval returns the parsed monitor interval +func (c *Config) GetMonitorInterval() (time.Duration, error) { + return time.ParseDuration(c.Monitor.Interval) +} \ No newline at end of file diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..aef05e9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,199 @@ +package config + +import ( + "os" + "testing" + "time" +) + +func TestLoadValidConfig(t *testing.T) { + content := ` +yandex: + token: "test-token" +artists: + - id: 12345 +storage: + path: "/data/music" + organize: true +monitor: + interval: "24h" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + cfg, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Yandex.Token != "test-token" { + t.Errorf("expected token 'test-token', got '%s'", cfg.Yandex.Token) + } + if len(cfg.Artists) != 1 || cfg.Artists[0].ID != 12345 { + t.Errorf("expected artist ID 12345, got %v", cfg.Artists) + } + if cfg.Storage.Path != "/data/music" { + t.Errorf("expected path '/data/music', got '%s'", cfg.Storage.Path) + } + if !cfg.Storage.Organize { + t.Error("expected organize=true") + } + interval, err := cfg.GetMonitorInterval() + if err != nil { + t.Fatalf("GetMonitorInterval failed: %v", err) + } + if interval != 24*time.Hour { + t.Errorf("expected 24h, got %v", interval) + } +} + +func TestLoadConfigWithUsernamePassword(t *testing.T) { + content := ` +yandex: + username: "user" + password: "pass" +artists: + - id: 1 +storage: + path: "/data" +monitor: + interval: "1h" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + cfg, err := Load(tmpfile.Name()) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Yandex.Username != "user" || cfg.Yandex.Password != "pass" { + t.Errorf("expected username/password, got %v", cfg.Yandex) + } +} + +func TestLoadConfigMissingAuth(t *testing.T) { + content := ` +yandex: {} +artists: + - id: 1 +storage: + path: "/data" +monitor: + interval: "1h" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if err == nil { + t.Error("expected error for missing auth") + } +} + +func TestLoadConfigNoArtists(t *testing.T) { + content := ` +yandex: + token: "test" +artists: [] +storage: + path: "/data" +monitor: + interval: "1h" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if err == nil { + t.Error("expected error for no artists") + } +} + +func TestLoadConfigInvalidInterval(t *testing.T) { + content := ` +yandex: + token: "test" +artists: + - id: 1 +storage: + path: "/data" +monitor: + interval: "invalid" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if err == nil { + t.Error("expected error for invalid interval") + } +} + +func TestLoadConfigNegativeArtistID(t *testing.T) { + content := ` +yandex: + token: "test" +artists: + - id: -1 +storage: + path: "/data" +monitor: + interval: "1h" +` + tmpfile, err := os.CreateTemp("", "config-*.yaml") + if err != nil { + t.Fatal(err) + } + defer os.Remove(tmpfile.Name()) + + if _, err := tmpfile.WriteString(content); err != nil { + t.Fatal(err) + } + tmpfile.Close() + + _, err = Load(tmpfile.Name()) + if err == nil { + t.Error("expected error for negative artist ID") + } +} \ No newline at end of file diff --git a/internal/downloader/downloader.go b/internal/downloader/downloader.go new file mode 100644 index 0000000..94783b1 --- /dev/null +++ b/internal/downloader/downloader.go @@ -0,0 +1,261 @@ +package downloader + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "ym-dl/internal/storage" + "ym-dl/internal/yandex" + "gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic" +) + +// DownloadResult represents the result of a download operation +type DownloadResult struct { + TrackID string + AlbumID string + Success bool + Error error + TrackMeta storage.TrackMeta + AlbumMeta storage.AlbumMeta +} + +// Downloader orchestrates the download of tracks and albums +type Downloader struct { + yandexClient *yandex.YandexClient + storage *storage.Storage + httpClient *http.Client +} + +// NewDownloader creates a new downloader +func NewDownloader(yandexClient *yandex.YandexClient, storage *storage.Storage) *Downloader { + return &Downloader{ + yandexClient: yandexClient, + storage: storage, + httpClient: &http.Client{Timeout: 5 * 60 * time.Second}, // 5 min timeout for large files + } +} + +// DownloadTrack downloads a single track +func (d *Downloader) DownloadTrack(ctx context.Context, trackID string) DownloadResult { + // Check if already downloaded + if d.storage.IsTrackDownloaded(trackID) { + meta, _ := d.storage.GetTrackMeta(trackID) + return DownloadResult{ + TrackID: trackID, + Success: true, + TrackMeta: meta, + } + } + + // Get track info from API with position + track, position, err := d.yandexClient.GetTrackInfoWithPosition(ctx, trackID) + if err != nil { + return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get track info: %w", err)} + } + + // Get album info if available + var album *yandexmusic.Album + var coverData []byte + if len(track.Albums) > 0 { + album = &track.Albums[0] + // Fetch album cover + if album.CoverURI != "" { + coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1) + coverURL = "https://" + strings.TrimPrefix(coverURL, "//") + coverData, _ = d.downloadFile(ctx, coverURL) + } + } + + // Determine track number and disc number from API response + trackNum := 1 + discNum := 1 + if position != nil { + trackNum = position.Index + discNum = position.Volume + } + + // Get direct download URL (handles XML parsing and URL signing) + downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, trackID) + if err != nil { + return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download URL: %w", err)} + } + + // Download the actual audio file + audioData, err := d.downloadFile(ctx, downloadURL) + if err != nil { + return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("download audio: %w", err)} + } + + // Get download info for metadata (codec, bitrate) + downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, trackID) + if err != nil { + return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("get download info: %w", err)} + } + + // Find best quality MP3 for metadata + var bestInfo *yandexmusic.DownloadInfo + for _, info := range downloadInfos { + if info.Codec == "mp3" && info.DirectLink { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + if bestInfo == nil { + for _, info := range downloadInfos { + if info.Codec == "mp3" { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + } + if bestInfo == nil && len(downloadInfos) > 0 { + bestInfo = &downloadInfos[0] + } + + // Build track meta with full metadata + trackMeta := storage.BuildTrackMetaFromYandex(track, album, trackNum, discNum, *bestInfo) + + if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil { + return DownloadResult{TrackID: trackID, Success: false, Error: fmt.Errorf("save track: %w", err)} + } + + savedMeta, _ := d.storage.GetTrackMeta(trackID) + return DownloadResult{ + TrackID: trackID, + Success: true, + TrackMeta: savedMeta, + } +} + +// DownloadAlbum downloads all tracks from an album +func (d *Downloader) DownloadAlbum(ctx context.Context, albumID string) []DownloadResult { + var results []DownloadResult + + // Get album with tracks + album, err := d.yandexClient.GetAlbumWithTracks(ctx, albumID) + if err != nil { + return []DownloadResult{{AlbumID: albumID, Success: false, Error: fmt.Errorf("get album: %w", err)}} + } + + // Get album cover + var coverData []byte + if album.CoverURI != "" { + coverURL := strings.Replace(album.CoverURI, "%%", "1000x1000", 1) + coverURL = "https://" + strings.TrimPrefix(coverURL, "//") + coverData, _ = d.downloadFile(ctx, coverURL) + } + + // Build track metas + var trackMetas []storage.TrackMeta + if album.Volumes != nil { + for i, volume := range album.Volumes { + for j := range volume { + track := &volume[j] + if track.ID == "" || !track.Available { + continue + } + + // Get download URL (handles XML parsing and URL signing) + downloadURL, err := d.yandexClient.GetTrackDownloadURL(ctx, track.ID) + if err != nil { + results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download URL: %w", err)}) + continue + } + + // Get download info for metadata + downloadInfos, err := d.yandexClient.GetTrackDownloadInfo(ctx, track.ID) + if err != nil { + results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("get download info: %w", err)}) + continue + } + + var bestInfo *yandexmusic.DownloadInfo + for _, info := range downloadInfos { + if info.Codec == "mp3" && info.DirectLink { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + if bestInfo == nil { + for _, info := range downloadInfos { + if info.Codec == "mp3" { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + } + if bestInfo == nil && len(downloadInfos) > 0 { + bestInfo = &downloadInfos[0] + } + + trackMeta := storage.BuildTrackMetaFromYandex(track, album, j+1, i+1, *bestInfo) + trackMetas = append(trackMetas, trackMeta) + + // Download audio + audioData, err := d.downloadFile(ctx, downloadURL) + if err != nil { + results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("download audio: %w", err)}) + continue + } + + // Save track + if err := d.storage.SaveTrack(trackMeta, audioData, coverData); err != nil { + results = append(results, DownloadResult{TrackID: track.ID, Success: false, Error: fmt.Errorf("save track: %w", err)}) + continue + } + + savedMeta, _ := d.storage.GetTrackMeta(track.ID) + results = append(results, DownloadResult{ + TrackID: track.ID, + AlbumID: albumID, + Success: true, + TrackMeta: savedMeta, + }) + + // Delay between downloads to be respectful to the API + select { + case <-ctx.Done(): + return results + case <-time.After(1 * time.Second): + } + } + } + } + + // Save album meta + albumMeta := storage.BuildAlbumMetaFromYandex(album, trackMetas) + if err := d.storage.SaveAlbum(albumMeta, coverData); err != nil { + results = append(results, DownloadResult{AlbumID: albumID, Success: false, Error: fmt.Errorf("save album: %w", err)}) + } + + return results +} + +// downloadFile downloads a file from a URL +func (d *Downloader) downloadFile(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := d.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d", resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + \ No newline at end of file diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..b9b283d --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,240 @@ +package monitor + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "ym-dl/internal/config" + "ym-dl/internal/downloader" + "ym-dl/internal/storage" + "ym-dl/internal/yandex" +) + +// Monitor periodically checks for new releases from configured artists +type Monitor struct { + cfg *config.Config + yandexClient *yandex.YandexClient + storage *storage.Storage + downloader *downloader.Downloader + logger *slog.Logger + + mu sync.Mutex + stopCh chan struct{} + wg sync.WaitGroup + running bool +} + +// NewMonitor creates a new monitor +func NewMonitor(cfg *config.Config, yandexClient *yandex.YandexClient, storage *storage.Storage, downloader *downloader.Downloader, logger *slog.Logger) *Monitor { + return &Monitor{ + cfg: cfg, + yandexClient: yandexClient, + storage: storage, + downloader: downloader, + logger: logger, + stopCh: make(chan struct{}), + } +} + +// Start starts the monitor loop +func (m *Monitor) Start(ctx context.Context) error { + m.mu.Lock() + if m.running { + m.mu.Unlock() + return fmt.Errorf("monitor already running") + } + m.running = true + m.mu.Unlock() + + m.wg.Add(1) + go m.runLoop(ctx) + + return nil +} + +// Stop stops the monitor +func (m *Monitor) Stop() { + m.mu.Lock() + if !m.running { + m.mu.Unlock() + return + } + m.running = false + close(m.stopCh) + m.mu.Unlock() + + m.wg.Wait() +} + +// runLoop is the main monitoring loop +func (m *Monitor) runLoop(ctx context.Context) { + defer m.wg.Done() + + interval, err := m.cfg.GetMonitorInterval() + if err != nil { + m.logger.Error("invalid monitor interval", "error", err) + return + } + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + // Run initial check + m.checkAllArtists(ctx) + + for { + select { + case <-ctx.Done(): + return + case <-m.stopCh: + return + case <-ticker.C: + m.checkAllArtists(ctx) + } + } +} + +// checkAllArtists checks all configured artists for new releases +func (m *Monitor) checkAllArtists(ctx context.Context) { + m.logger.Info("starting artist check", "artist_count", len(m.cfg.Artists)) + + for _, artist := range m.cfg.Artists { + select { + case <-ctx.Done(): + return + case <-m.stopCh: + return + default: + } + + m.checkArtist(ctx, artist.ID) + } +} + +// checkArtist checks a single artist for new tracks and albums +func (m *Monitor) checkArtist(ctx context.Context, artistID int) { + m.logger.Info("checking artist", "artist_id", artistID) + + // Get artist info + artist, err := m.yandexClient.GetArtistInfo(ctx, artistID) + if err != nil { + m.logger.Error("failed to get artist info", "artist_id", artistID, "error", err) + return + } + + m.logger.Info("artist info", "artist_id", artistID, "name", artist.Name) + + // Check for new tracks + m.checkArtistTracks(ctx, artistID, artist.Name) + + // Check for new albums + m.checkArtistAlbums(ctx, artistID, artist.Name) +} + +// checkArtistTracks checks for new tracks from an artist +func (m *Monitor) checkArtistTracks(ctx context.Context, artistID int, artistName string) { + page := 0 + pageSize := 50 + + for { + tracks, err := m.yandexClient.GetArtistTracks(ctx, artistID, page, pageSize) + if err != nil { + m.logger.Error("failed to get artist tracks", "artist_id", artistID, "page", page, "error", err) + return + } + + if len(tracks) == 0 { + break + } + + newTracks := 0 + for _, track := range tracks { + if !m.storage.IsTrackDownloaded(track.ID) { + m.logger.Info("new track found", "track_id", track.ID, "artist", artistName) + result := m.downloader.DownloadTrack(ctx, track.ID) + if result.Success { + newTracks++ + } else { + m.logger.Error("failed to download track", "track_id", track.ID, "error", result.Error) + } + } + } + + m.logger.Info("track check complete", "artist_id", artistID, "new_tracks", newTracks, "page", page) + + if len(tracks) < pageSize { + break + } + page++ + } +} + +// checkArtistAlbums checks for new albums from an artist +func (m *Monitor) checkArtistAlbums(ctx context.Context, artistID int, artistName string) { + page := 0 + pageSize := 50 + + for { + albums, err := m.yandexClient.GetArtistAlbums(ctx, artistID, page, pageSize) + if err != nil { + m.logger.Error("failed to get artist albums", "artist_id", artistID, "page", page, "error", err) + return + } + + if len(albums) == 0 { + break + } + + newAlbums := 0 + for _, album := range albums { + albumIDStr := album.ID + if !m.isAlbumDownloaded(albumIDStr, album.TrackCount) { + m.logger.Info("new album found", "album_id", albumIDStr, "title", album.Title, "artist", artistName) + results := m.downloader.DownloadAlbum(ctx, album.ID) + success := false + for _, r := range results { + if r.Success { + success = true + break + } + } + if success { + newAlbums++ + } else { + m.logger.Error("failed to download album", "album_id", albumIDStr, "title", album.Title) + } + } + } + + m.logger.Info("album check complete", "artist_id", artistID, "new_albums", newAlbums, "page", page) + + if len(albums) < pageSize { + break + } + page++ + } +} + +// isAlbumDownloaded checks if an album has been fully downloaded. +// An album is considered downloaded only if all its expected tracks (by TrackCount) +// are present in the storage index. If TrackCount is 0 (unknown), the album is +// never considered pre-downloaded to avoid skipping compilation albums +// where only some tracks were fetched individually. +func (m *Monitor) isAlbumDownloaded(albumID string, expectedTrackCount int) bool { + if expectedTrackCount <= 0 { + return false // Unknown track count — download anyway to be safe + } + + downloadedCount := 0 + for _, trackID := range m.storage.GetDownloadedTracks() { + meta, ok := m.storage.GetTrackMeta(trackID) + if ok && meta.AlbumID == albumID { + downloadedCount++ + } + } + + return downloadedCount >= expectedTrackCount +} \ No newline at end of file diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..79639a8 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1,754 @@ +package storage + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/bogem/id3v2" + _ "github.com/mattn/go-sqlite3" + "gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic" +) + +// TrackMeta holds metadata for a downloaded track +type TrackMeta struct { + TrackID string `json:"track_id"` + AlbumID string `json:"album_id"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + ArtistName string `json:"artist_name"` // Primary artist (first) + ArtistNames []string `json:"artist_names"` // All artists + AlbumTitle string `json:"album_title"` + AlbumArtist string `json:"album_artist"` // Album artist (may differ from track artist) + AlbumArtistID string `json:"album_artist_id"` // Album artist ID + Year int `json:"year"` // Release year from album + TrackNumber int `json:"track_number"` + DiscNumber int `json:"disc_number"` + DurationMs int64 `json:"duration_ms"` + Bitrate int `json:"bitrate"` + Codec string `json:"codec"` + FilePath string `json:"file_path"` + FileSize int64 `json:"file_size"` + DownloadedAt string `json:"downloaded_at"` + + // Extended metadata from Yandex Music API + Genre string `json:"genre"` // Track or album genre + Comment string `json:"comment"` // Album description / comments + Lyrics string `json:"lyrics"` // Track lyrics text + Publisher string `json:"publisher"` // Album label / publisher + EncodedBy string `json:"encoded_by"` // Encoder name + OriginalYear string `json:"original_year"` // Original release year + Composer string `json:"composer"` // Composer + ISRC string `json:"isrc"` // International Standard Recording Code + Copyright string `json:"copyright"` // Copyright information +} + +// AlbumMeta holds metadata for a downloaded album +type AlbumMeta struct { + AlbumID string `json:"album_id"` + ArtistID string `json:"artist_id"` + Title string `json:"title"` + ArtistName string `json:"artist_name"` + Year int `json:"year"` + TrackCount int `json:"track_count"` + CoverPath string `json:"cover_path"` + Tracks []TrackMeta `json:"tracks"` + DownloadedAt string `json:"downloaded_at"` +} + +// Storage manages local file storage for downloaded music +type Storage struct { + rootPath string + organize bool + mu sync.RWMutex + db *sql.DB +} + +// NewStorage creates a new storage instance with SQLite backend +func NewStorage(rootPath string, organize bool) (*Storage, error) { + if err := os.MkdirAll(rootPath, 0755); err != nil { + return nil, fmt.Errorf("create root path: %w", err) + } + + dbPath := filepath.Join(rootPath, "index.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return nil, fmt.Errorf("open sqlite db: %w", err) + } + + // Configure connection pool for single-writer SQLite + db.SetMaxOpenConns(1) + + if err := createSchema(db); err != nil { + db.Close() + return nil, fmt.Errorf("create schema: %w", err) + } + + s := &Storage{ + rootPath: rootPath, + organize: organize, + db: db, + } + + // Migrate from old JSON index if it exists + if err := s.migrateFromJSON(); err != nil { + // Log but don't fail — migration is best-effort + fmt.Printf("Warning: JSON index migration: %v\n", err) + } + + return s, nil +} + +// createSchema creates the tracks table if it doesn't exist +func createSchema(db *sql.DB) error { + schema := ` + CREATE TABLE IF NOT EXISTS tracks ( + track_id TEXT PRIMARY KEY, + album_id TEXT, + artist_id TEXT, + title TEXT, + artist_name TEXT, + artist_names TEXT, + album_title TEXT, + album_artist TEXT, + album_artist_id TEXT, + year INTEGER, + track_number INTEGER, + disc_number INTEGER, + duration_ms INTEGER, + bitrate INTEGER, + codec TEXT, + file_path TEXT, + file_size INTEGER, + downloaded_at TEXT, + genre TEXT, + comment TEXT, + lyrics TEXT, + publisher TEXT, + encoded_by TEXT, + original_year TEXT, + composer TEXT, + isrc TEXT, + copyright TEXT + ); + CREATE INDEX IF NOT EXISTS idx_tracks_album_id ON tracks(album_id); + ` + _, err := db.Exec(schema) + return err +} + +// migrateFromJSON imports the old .index.json into SQLite if it exists +func (s *Storage) migrateFromJSON() error { + jsonPath := filepath.Join(s.rootPath, ".index.json") + data, err := os.ReadFile(jsonPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil // No old index, nothing to migrate + } + return err + } + + var oldIndex map[string]TrackMeta + if err := json.Unmarshal(data, &oldIndex); err != nil { + return fmt.Errorf("parse old index: %w", err) + } + + if len(oldIndex) == 0 { + return nil + } + + // Check if SQLite already has data (avoid double-migration) + var count int + err = s.db.QueryRow("SELECT COUNT(*) FROM tracks").Scan(&count) + if err != nil { + return err + } + if count > 0 { + return nil // Already migrated + } + + tx, err := s.db.Begin() + if err != nil { + return err + } + stmt, err := tx.Prepare(insertTrackSQL()) + if err != nil { + tx.Rollback() + return err + } + defer stmt.Close() + + for _, meta := range oldIndex { + if _, err := stmt.Exec( + meta.TrackID, meta.AlbumID, meta.ArtistID, meta.Title, meta.ArtistName, + serializeStrings(meta.ArtistNames), meta.AlbumTitle, meta.AlbumArtist, + meta.AlbumArtistID, meta.Year, meta.TrackNumber, meta.DiscNumber, + meta.DurationMs, meta.Bitrate, meta.Codec, meta.FilePath, meta.FileSize, + meta.DownloadedAt, meta.Genre, meta.Comment, meta.Lyrics, meta.Publisher, + meta.EncodedBy, meta.OriginalYear, meta.Composer, meta.ISRC, meta.Copyright, + ); err != nil { + tx.Rollback() + return err + } + } + + if err := tx.Commit(); err != nil { + return err + } + + // Remove the old JSON file after successful migration + if err := os.Remove(jsonPath); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + + return nil +} + +// insertTrackSQL returns the parameterized INSERT OR REPLACE statement +func insertTrackSQL() string { + return ` + INSERT OR REPLACE INTO tracks ( + track_id, album_id, artist_id, title, artist_name, artist_names, + album_title, album_artist, album_artist_id, year, track_number, + disc_number, duration_ms, bitrate, codec, file_path, file_size, + downloaded_at, genre, comment, lyrics, publisher, encoded_by, + original_year, composer, isrc, copyright + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +` +} + +// scanTrackMeta reads a TrackMeta from a sql.Row +func scanTrackMeta(row *sql.Row) (TrackMeta, error) { + var m TrackMeta + var artistNamesStr string + var downloadedAt sql.NullString + var genre sql.NullString + var comment sql.NullString + var lyrics sql.NullString + var publisher sql.NullString + var encodedBy sql.NullString + var originalYear sql.NullString + var composer sql.NullString + var isrc sql.NullString + var copyright sql.NullString + + err := row.Scan( + &m.TrackID, &m.AlbumID, &m.ArtistID, &m.Title, &m.ArtistName, + &artistNamesStr, &m.AlbumTitle, &m.AlbumArtist, &m.AlbumArtistID, + &m.Year, &m.TrackNumber, &m.DiscNumber, &m.DurationMs, &m.Bitrate, + &m.Codec, &m.FilePath, &m.FileSize, &downloadedAt, &genre, &comment, + &lyrics, &publisher, &encodedBy, &originalYear, &composer, &isrc, + ©right, + ) + if err != nil { + return m, err + } + + if downloadedAt.Valid { + m.DownloadedAt = downloadedAt.String + } + m.ArtistNames = deserializeStrings(artistNamesStr) + if genre.Valid { + m.Genre = genre.String + } + if comment.Valid { + m.Comment = comment.String + } + if lyrics.Valid { + m.Lyrics = lyrics.String + } + if publisher.Valid { + m.Publisher = publisher.String + } + if encodedBy.Valid { + m.EncodedBy = encodedBy.String + } + if originalYear.Valid { + m.OriginalYear = originalYear.String + } + if composer.Valid { + m.Composer = composer.String + } + if isrc.Valid { + m.ISRC = isrc.String + } + if copyright.Valid { + m.Copyright = copyright.String + } + + return m, nil +} + +// serializeStrings converts a []string to a JSON string for storage +func serializeStrings(strs []string) string { + if strs == nil { + return "[]" + } + b, _ := json.Marshal(strs) + return string(b) +} + +// deserializeStrings converts a JSON string back to []string +func deserializeStrings(s string) []string { + if s == "" || s == "[]" { + return nil + } + var strs []string + if err := json.Unmarshal([]byte(s), &strs); err != nil { + return nil + } + return strs +} + +// IsTrackDownloaded checks if a track has already been downloaded +func (s *Storage) IsTrackDownloaded(trackID string) bool { + s.mu.RLock() + defer s.mu.RUnlock() + + var count int + err := s.db.QueryRow("SELECT COUNT(*) FROM tracks WHERE track_id = ?", trackID).Scan(&count) + if err != nil { + return false + } + return count > 0 +} + +// GetTrackMeta returns metadata for a downloaded track +func (s *Storage) GetTrackMeta(trackID string) (TrackMeta, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + + m, err := scanTrackMeta(s.db.QueryRow("SELECT * FROM tracks WHERE track_id = ?", trackID)) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return TrackMeta{}, false + } + return TrackMeta{}, false + } + return m, true +} + +// SaveTrack saves a track file and its metadata +func (s *Storage) SaveTrack(trackMeta TrackMeta, data []byte, coverData []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + // Determine file path + var filePath string + if s.organize { + // Artist/Album/TrackNumber - Title.ext + artistDir := sanitizeFilename(trackMeta.ArtistName) + albumDir := sanitizeFilename(trackMeta.AlbumTitle) + trackFilename := fmt.Sprintf("%02d - %s.%s", trackMeta.TrackNumber, sanitizeFilename(trackMeta.Title), trackMeta.Codec) + filePath = filepath.Join(s.rootPath, artistDir, albumDir, trackFilename) + } else { + // Flat structure: Artist - Title.ext + trackFilename := fmt.Sprintf("%s - %s.%s", sanitizeFilename(trackMeta.ArtistName), sanitizeFilename(trackMeta.Title), trackMeta.Codec) + filePath = filepath.Join(s.rootPath, trackFilename) + } + + // Create directory + dir := filepath.Dir(filePath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + // Write file + if err := os.WriteFile(filePath, data, 0644); err != nil { + return fmt.Errorf("write track file: %w", err) + } + + // Write ID3 tags (only for MP3) + if strings.EqualFold(trackMeta.Codec, "mp3") { + if err := writeID3Tags(filePath, trackMeta, coverData); err != nil { + // Log error but don't fail the download + fmt.Printf("Warning: failed to write ID3 tags: %v\n", err) + } + } + + // Update metadata + trackMeta.FilePath = filePath + trackMeta.FileSize = int64(len(data)) + trackMeta.DownloadedAt = time.Now().Format(time.RFC3339) + + _, err := s.db.Exec(insertTrackSQL(), + trackMeta.TrackID, trackMeta.AlbumID, trackMeta.ArtistID, trackMeta.Title, + trackMeta.ArtistName, serializeStrings(trackMeta.ArtistNames), + trackMeta.AlbumTitle, trackMeta.AlbumArtist, trackMeta.AlbumArtistID, + trackMeta.Year, trackMeta.TrackNumber, trackMeta.DiscNumber, + trackMeta.DurationMs, trackMeta.Bitrate, trackMeta.Codec, + trackMeta.FilePath, trackMeta.FileSize, trackMeta.DownloadedAt, + trackMeta.Genre, trackMeta.Comment, trackMeta.Lyrics, trackMeta.Publisher, + trackMeta.EncodedBy, trackMeta.OriginalYear, trackMeta.Composer, + trackMeta.ISRC, trackMeta.Copyright, + ) + if err != nil { + return fmt.Errorf("save track meta: %w", err) + } + + return nil +} + +// SaveAlbum saves album cover and metadata +func (s *Storage) SaveAlbum(albumMeta AlbumMeta, coverData []byte) error { + s.mu.Lock() + defer s.mu.Unlock() + + var coverPath string + if s.organize && len(albumMeta.Tracks) > 0 { + artistDir := sanitizeFilename(albumMeta.ArtistName) + albumDir := sanitizeFilename(albumMeta.Title) + coverPath = filepath.Join(s.rootPath, artistDir, albumDir, "cover.jpg") + } else { + coverPath = filepath.Join(s.rootPath, fmt.Sprintf("%s - %s_cover.jpg", sanitizeFilename(albumMeta.ArtistName), sanitizeFilename(albumMeta.Title))) + } + + dir := filepath.Dir(coverPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + + if len(coverData) > 0 { + if err := os.WriteFile(coverPath, coverData, 0644); err != nil { + return fmt.Errorf("write cover file: %w", err) + } + } + + albumMeta.CoverPath = coverPath + // Update track metas with album info + for i := range albumMeta.Tracks { + if _, err := s.db.Exec(insertTrackSQL(), + albumMeta.Tracks[i].TrackID, albumMeta.Tracks[i].AlbumID, + albumMeta.Tracks[i].ArtistID, albumMeta.Tracks[i].Title, + albumMeta.Tracks[i].ArtistName, + serializeStrings(albumMeta.Tracks[i].ArtistNames), + albumMeta.Tracks[i].AlbumTitle, albumMeta.Tracks[i].AlbumArtist, + albumMeta.Tracks[i].AlbumArtistID, albumMeta.Tracks[i].Year, + albumMeta.Tracks[i].TrackNumber, albumMeta.Tracks[i].DiscNumber, + albumMeta.Tracks[i].DurationMs, albumMeta.Tracks[i].Bitrate, + albumMeta.Tracks[i].Codec, albumMeta.Tracks[i].FilePath, + albumMeta.Tracks[i].FileSize, albumMeta.Tracks[i].DownloadedAt, + albumMeta.Tracks[i].Genre, albumMeta.Tracks[i].Comment, + albumMeta.Tracks[i].Lyrics, albumMeta.Tracks[i].Publisher, + albumMeta.Tracks[i].EncodedBy, albumMeta.Tracks[i].OriginalYear, + albumMeta.Tracks[i].Composer, albumMeta.Tracks[i].ISRC, + albumMeta.Tracks[i].Copyright, + ); err != nil { + return fmt.Errorf("save album track meta: %w", err) + } + } + + return nil +} + +// GetDownloadedTracks returns all downloaded track IDs +func (s *Storage) GetDownloadedTracks() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query("SELECT track_id FROM tracks") + if err != nil { + return nil + } + defer rows.Close() + + var tracks []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + tracks = append(tracks, id) + } + return tracks +} + +// GetDownloadedAlbums returns all downloaded album IDs +func (s *Storage) GetDownloadedAlbums() []string { + s.mu.RLock() + defer s.mu.RUnlock() + + rows, err := s.db.Query("SELECT DISTINCT album_id FROM tracks WHERE album_id != '' AND album_id IS NOT NULL") + if err != nil { + return nil + } + defer rows.Close() + + var albums []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + continue + } + albums = append(albums, id) + } + return albums +} + +// Close closes the database connection +func (s *Storage) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.db != nil { + return s.db.Close() + } + return nil +} + +// sanitizeFilename removes invalid characters from filenames +func sanitizeFilename(name string) string { + replacer := strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + "\n", "_", + "\r", "_", + ) + return replacer.Replace(name) +} + +// BuildTrackMetaFromYandex creates TrackMeta from Yandex API data +func BuildTrackMetaFromYandex(track *yandexmusic.Track, album *yandexmusic.Album, trackNum, discNum int, downloadInfo yandexmusic.DownloadInfo) TrackMeta { + artistNames := make([]string, 0, len(track.Artists)) + for _, a := range track.Artists { + artistNames = append(artistNames, a.Name) + } + artistName := "" + if len(artistNames) > 0 { + artistName = artistNames[0] + } + + albumTitle := "" + albumID := "" + year := 0 + var albumGenre, albumLabel, albumDesc, albumReleaseDate, albumCopyright string + if album != nil { + albumTitle = album.Title + albumID = album.ID + year = album.Year + albumGenre = album.Genre + albumLabel = album.Label + albumDesc = album.Description + albumReleaseDate = album.ReleaseDate + if len(album.CatalogNumber) > 0 { + albumCopyright = album.CatalogNumber + } + } else if len(track.Albums) > 0 { + albumTitle = track.Albums[0].Title + albumID = track.Albums[0].ID + year = track.Albums[0].Year + } + + artistID := "" + if len(track.Artists) > 0 { + artistID = track.Artists[0].ID + } + + albumArtist := "" + albumArtistID := "" + if album != nil && len(album.Artists) > 0 { + albumArtist = album.Artists[0].Name + albumArtistID = album.Artists[0].ID + } else if len(track.Albums) > 0 && len(track.Albums[0].Artists) > 0 { + albumArtist = track.Albums[0].Artists[0].Name + albumArtistID = track.Albums[0].Artists[0].ID + } + + // Genre: prefer track genre, fallback to album genre + genre := track.Genre + if genre == "" { + genre = albumGenre + } + + // Publisher: album label + publisher := albumLabel + + // Original year from release date + var originalYear string + if albumReleaseDate != "" { + if len(albumReleaseDate) >= 4 { + originalYear = albumReleaseDate[:4] + } + } + + // Lyrics + var lyrics string + if track.Lyrics != nil && track.Lyrics.HasText { + lyrics = track.Lyrics.Text + } + + return TrackMeta{ + TrackID: track.ID, + AlbumID: albumID, + ArtistID: artistID, + Title: track.Title, + ArtistName: artistName, + ArtistNames: artistNames, + AlbumTitle: albumTitle, + AlbumArtist: albumArtist, + AlbumArtistID: albumArtistID, + Year: year, + TrackNumber: trackNum, + DiscNumber: discNum, + DurationMs: int64(track.DurationMs), + Bitrate: downloadInfo.Bitrate, + Codec: downloadInfo.Codec, + DownloadedAt: "", + + // Extended metadata + Genre: genre, + Comment: albumDesc, + Lyrics: lyrics, + Publisher: publisher, + EncodedBy: "ym-dl", + OriginalYear: originalYear, + Composer: "", + ISRC: "", + Copyright: albumCopyright, + } +} + +// BuildAlbumMetaFromYandex creates AlbumMeta from Yandex API data +func BuildAlbumMetaFromYandex(album *yandexmusic.Album, tracks []TrackMeta) AlbumMeta { + artistName := "" + artistID := "" + if len(album.Artists) > 0 { + artistName = album.Artists[0].Name + artistID = album.Artists[0].ID + } + + return AlbumMeta{ + AlbumID: album.ID, + ArtistID: artistID, + Title: album.Title, + ArtistName: artistName, + Year: album.Year, + TrackCount: len(tracks), + Tracks: tracks, + DownloadedAt: "", + } +} + +// writeID3Tags writes ID3v2 tags to an MP3 file +func writeID3Tags(filePath string, meta TrackMeta, coverData []byte) error { + tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) + if err != nil { + // Create new tag if file doesn't have one + tag = id3v2.NewEmptyTag() + } + defer tag.Close() + + // Join all artists with "; " for the artist tag + artistString := strings.Join(meta.ArtistNames, "; ") + + // Set basic tags + tag.SetTitle(meta.Title) + tag.SetArtist(artistString) + tag.SetAlbum(meta.AlbumTitle) + // Album artist (TPE2 frame) - use album artist if different from track artist + if meta.AlbumArtist != "" && meta.AlbumArtist != meta.ArtistName { + tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, meta.AlbumArtist) + } + // Use album year from metadata, fallback to current year + year := meta.Year + if year == 0 { + year = time.Now().Year() + } + tag.SetYear(fmt.Sprintf("%d", year)) + + if meta.TrackNumber > 0 { + // Track number via TRCK frame + tag.AddTextFrame("TRCK", id3v2.EncodingUTF8, fmt.Sprintf("%d", meta.TrackNumber)) + } + + if meta.DiscNumber > 0 { + // Disc number via TPOS frame + tag.AddTextFrame("TPOS", id3v2.EncodingUTF8, fmt.Sprintf("%d", meta.DiscNumber)) + } + + // Genre (TCON) + if meta.Genre != "" { + tag.SetGenre(meta.Genre) + } + + // Composer (TCOM) + if meta.Composer != "" { + tag.AddTextFrame("TCOM", id3v2.EncodingUTF8, meta.Composer) + } + + // Publisher (TPUB) + if meta.Publisher != "" { + tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, meta.Publisher) + } + + // Encoded by (TENC) + if meta.EncodedBy != "" { + tag.AddTextFrame("TENC", id3v2.EncodingUTF8, meta.EncodedBy) + } + + // Copyright (TCOP) + if meta.Copyright != "" { + tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, meta.Copyright) + } + + // Original year (TORY) + if meta.OriginalYear != "" { + tag.AddTextFrame("TORY", id3v2.EncodingUTF8, meta.OriginalYear) + } + + // ISRC (TSRC) + if meta.ISRC != "" { + tag.AddTextFrame("TSRC", id3v2.EncodingUTF8, meta.ISRC) + } + + // Comment (COMM) + if meta.Comment != "" { + tag.AddCommentFrame(id3v2.CommentFrame{ + Encoding: id3v2.EncodingUTF8, + Language: "eng", + Description: "Comment", + Text: meta.Comment, + }) + } + + // Lyrics (USLT) + if meta.Lyrics != "" { + tag.AddUnsynchronisedLyricsFrame(id3v2.UnsynchronisedLyricsFrame{ + Encoding: id3v2.EncodingUTF8, + Language: "eng", + ContentDescriptor: "Lyrics", + Lyrics: meta.Lyrics, + }) + } + + // Add cover art (APIC frame) + if len(coverData) > 0 { + tag.AddAttachedPicture(id3v2.PictureFrame{ + Encoding: id3v2.EncodingUTF8, + MimeType: "image/jpeg", + PictureType: id3v2.PTFrontCover, + Description: "Cover", + Picture: coverData, + }) + } + + // Add custom TXXX frames for Yandex IDs + tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_TRACK_ID: "+meta.TrackID) + tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_ALBUM_ID: "+meta.AlbumID) + tag.AddTextFrame("TXXX", id3v2.EncodingUTF8, "YANDEX_ARTIST_ID: "+meta.ArtistID) + + // Save tag + return tag.Save() +} diff --git a/internal/storage/storage_test.go b/internal/storage/storage_test.go new file mode 100644 index 0000000..0b9ebe9 --- /dev/null +++ b/internal/storage/storage_test.go @@ -0,0 +1,291 @@ +package storage + +import ( + "path/filepath" + "os" + "testing" + "time" + + "gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic" +) + +func TestStorageNew(t *testing.T) { + tmpDir := t.TempDir() + + s, err := NewStorage(tmpDir, true) + if err != nil { + t.Fatalf("NewStorage failed: %v", err) + } + if s == nil { + t.Fatal("expected non-nil storage") + } +} + +func TestStorageSaveAndLoadTrack(t *testing.T) { + tmpDir := t.TempDir() + + s, err := NewStorage(tmpDir, true) + if err != nil { + t.Fatalf("NewStorage failed: %v", err) + } + + meta := TrackMeta{ + TrackID: "123", + AlbumID: "456", + ArtistID: "789", + Title: "Test Track", + ArtistName: "Test Artist", + AlbumTitle: "Test Album", + TrackNumber: 1, + DiscNumber: 1, + DurationMs: 180000, + Bitrate: 320, + Codec: "mp3", + DownloadedAt: time.Now().Format(time.RFC3339), + } + + data := []byte("fake audio data") + + if err := s.SaveTrack(meta, data, nil); err != nil { + t.Fatalf("SaveTrack failed: %v", err) + } + + // Check index + if !s.IsTrackDownloaded("123") { + t.Error("track not marked as downloaded") + } + + loadedMeta, ok := s.GetTrackMeta("123") + if !ok { + t.Fatal("track not found in index") + } + if loadedMeta.Title != "Test Track" { + t.Errorf("expected title 'Test Track', got '%s'", loadedMeta.Title) + } + if loadedMeta.FileSize != int64(len(data)) { + t.Errorf("expected file size %d, got %d", len(data), loadedMeta.FileSize) + } + // Check file exists using the stored path + if _, err := os.Stat(loadedMeta.FilePath); os.IsNotExist(err) { + t.Error("track file not created") + } +} + +func TestStorageIdempotent(t *testing.T) { + tmpDir := t.TempDir() + + s, err := NewStorage(tmpDir, true) + if err != nil { + t.Fatalf("NewStorage failed: %v", err) + } + + meta := TrackMeta{ + TrackID: "123", + Title: "Test Track", + ArtistName: "Test Artist", + AlbumTitle: "Test Album", + TrackNumber: 1, + Codec: "mp3", + } + + data := []byte("fake audio data") + + // Save first time + if err := s.SaveTrack(meta, data, nil); err != nil { + t.Fatalf("first SaveTrack failed: %v", err) + } + + firstPath := meta.FilePath + + // Save second time (should not create duplicate) + if err := s.SaveTrack(meta, data, nil); err != nil { + t.Fatalf("second SaveTrack failed: %v", err) + } + + if meta.FilePath != firstPath { + t.Errorf("file path changed on second save: %s vs %s", meta.FilePath, firstPath) + } + + // Verify only one file exists + files, _ := os.ReadDir(filepath.Join(tmpDir, "Test Artist", "Test Album")) + count := 0 + for _, f := range files { + if !f.IsDir() { + count++ + } + } + if count != 1 { + t.Errorf("expected 1 file, found %d", count) + } +} + +func TestStorageOrganizeFalse(t *testing.T) { + tmpDir := t.TempDir() + + s, err := NewStorage(tmpDir, false) + if err != nil { + t.Fatalf("NewStorage failed: %v", err) + } + + meta := TrackMeta{ + TrackID: "123", + Title: "Test Track", + ArtistName: "Test Artist", + AlbumTitle: "Test Album", + TrackNumber: 1, + Codec: "mp3", + DownloadedAt: time.Now().Format(time.RFC3339), + } + + data := []byte("fake audio data") + + if err := s.SaveTrack(meta, data, nil); err != nil { + t.Fatalf("SaveTrack failed: %v", err) + } + + // Should be flat structure - get the saved meta from index + loadedMeta, ok := s.GetTrackMeta("123") + if !ok { + t.Fatal("track not found in index") + } + expectedDir := tmpDir + if filepath.Dir(loadedMeta.FilePath) != expectedDir { + t.Errorf("expected flat structure in %s, got %s", expectedDir, filepath.Dir(loadedMeta.FilePath)) + } +} + +func TestStorageSanitizeFilename(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"normal", "normal"}, + {"with/slash", "with_slash"}, + {"with\\backslash", "with_backslash"}, + {"with:colon", "with_colon"}, + {"with*star", "with_star"}, + {"with?question", "with_question"}, + {"with\"quote", "with_quote"}, + {"withgt", "with_gt"}, + {"with|pipe", "with_pipe"}, + } + + for _, tc := range tests { + result := sanitizeFilename(tc.input) + if result != tc.expected { + t.Errorf("sanitizeFilename(%q) = %q, want %q", tc.input, result, tc.expected) + } + } +} + +func TestStorageBuildTrackMetaFromYandex(t *testing.T) { + track := &yandexmusic.Track{ + ID: "123", + Title: "Test Track", + Genre: "Pop", + DurationMs: 180000, + Artists: []yandexmusic.Artist{ + {ID: "789", Name: "Test Artist"}, + }, + Albums: []yandexmusic.Album{ + {ID: "456", Title: "Test Album"}, + }, + } + + album := &yandexmusic.Album{ + ID: "456", + Title: "Test Album", + Genre: "Album Pop", + Year: 2023, + ReleaseDate: "2023-05-15", + Label: "Test Label", + Description: "Test album description", + CatalogNumber: "TC-001", + Artists: []yandexmusic.Artist{ + {ID: "789", Name: "Test Artist"}, + }, + } + + downloadInfo := yandexmusic.DownloadInfo{ + Bitrate: 320, + Codec: "mp3", + } + + meta := BuildTrackMetaFromYandex(track, album, 1, 1, downloadInfo) + + if meta.TrackID != "123" { + t.Errorf("expected track ID 123, got %s", meta.TrackID) + } + if meta.Title != "Test Track" { + t.Errorf("expected title 'Test Track', got '%s'", meta.Title) + } + if meta.ArtistName != "Test Artist" { + t.Errorf("expected artist 'Test Artist', got '%s'", meta.ArtistName) + } + if meta.AlbumTitle != "Test Album" { + t.Errorf("expected album 'Test Album', got '%s'", meta.AlbumTitle) + } + if meta.Bitrate != 320 { + t.Errorf("expected bitrate 320, got %d", meta.Bitrate) + } + if meta.Codec != "mp3" { + t.Errorf("expected codec mp3, got %s", meta.Codec) + } + // Extended metadata checks + if meta.Genre != "Pop" { + t.Errorf("expected genre 'Pop', got '%s'", meta.Genre) + } + if meta.Comment != "Test album description" { + t.Errorf("expected comment 'Test album description', got '%s'", meta.Comment) + } + if meta.Lyrics != "" { + t.Errorf("expected empty lyrics (no lyrics in track), got '%s'", meta.Lyrics) + } + if meta.Publisher != "Test Label" { + t.Errorf("expected publisher 'Test Label', got '%s'", meta.Publisher) + } + if meta.EncodedBy != "ym-dl" { + t.Errorf("expected encoded_by 'ym-dl', got '%s'", meta.EncodedBy) + } + if meta.OriginalYear != "2023" { + t.Errorf("expected original_year '2023', got '%s'", meta.OriginalYear) + } + if meta.Copyright != "TC-001" { + t.Errorf("expected copyright 'TC-001', got '%s'", meta.Copyright) + } +} + +func TestStorageBuildAlbumMetaFromYandex(t *testing.T) { + album := &yandexmusic.Album{ + ID: "456", + Title: "Test Album", + Artists: []yandexmusic.Artist{ + {ID: "789", Name: "Test Artist"}, + }, + Year: 2023, + } + + tracks := []TrackMeta{ + {TrackID: "1", Title: "Track 1"}, + {TrackID: "2", Title: "Track 2"}, + } + + meta := BuildAlbumMetaFromYandex(album, tracks) + + if meta.AlbumID != "456" { + t.Errorf("expected album ID 456, got %s", meta.AlbumID) + } + if meta.Title != "Test Album" { + t.Errorf("expected title 'Test Album', got '%s'", meta.Title) + } + if meta.ArtistName != "Test Artist" { + t.Errorf("expected artist 'Test Artist', got '%s'", meta.ArtistName) + } + if meta.Year != 2023 { + t.Errorf("expected year 2023, got %d", meta.Year) + } + if meta.TrackCount != 2 { + t.Errorf("expected track count 2, got %d", meta.TrackCount) + } +} \ No newline at end of file diff --git a/internal/yandex/client.go b/internal/yandex/client.go new file mode 100644 index 0000000..5a7a672 --- /dev/null +++ b/internal/yandex/client.go @@ -0,0 +1,241 @@ +package yandex + +import ( + "context" + "crypto/md5" + "encoding/hex" + "encoding/xml" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "ym-dl/internal/config" + "gitea.mrixs.me/Mrixs/yandex-music-api-go/yandexmusic" +) + +const downloadSalt = "XGRlBW9FXlekgbPrRHuSiA" // Magic salt for URL signing + +// TrackPosition represents track position in album +type TrackPosition struct { + Volume int `json:"volume"` + Index int `json:"index"` +} + +// YandexClient wraps the external yandexmusic client +type YandexClient struct { + client *yandexmusic.Client + config *config.YandexConfig + httpClient *http.Client + debug bool + logger *slog.Logger +} + +// NewYandexClient creates a new Yandex Music client wrapper +func NewYandexClient(cfg *config.YandexConfig, debug bool) (*YandexClient, error) { + if cfg.Token == "" { + return nil, errors.New("yandex.token is required") + } + + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + logLevel := slog.LevelInfo + if debug { + logLevel = slog.LevelDebug + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: logLevel, + })) + + client := yandexmusic.NewClient(yandexmusic.ClientConfig{ + Token: cfg.Token, + }) + + if err := client.Init(context.Background()); err != nil { + return nil, fmt.Errorf("init client: %w", err) + } + + return &YandexClient{ + client: client, + config: cfg, + httpClient: httpClient, + debug: debug, + logger: logger, + }, nil +} + +// GetArtistTracks fetches tracks for an artist +func (c *YandexClient) GetArtistTracks(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Track, error) { + return c.client.GetArtistTracks(ctx, fmt.Sprintf("%d", artistID), page, pageSize) +} + +// GetArtistAlbums fetches albums for an artist +func (c *YandexClient) GetArtistAlbums(ctx context.Context, artistID int, page, pageSize int) ([]*yandexmusic.Album, error) { + return c.client.GetArtistAlbums(ctx, fmt.Sprintf("%d", artistID), page, pageSize) +} + +// GetAlbumWithTracks fetches an album with its tracks +func (c *YandexClient) GetAlbumWithTracks(ctx context.Context, albumID string) (*yandexmusic.Album, error) { + albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID}) + if err != nil { + return nil, err + } + if len(albums) == 0 { + return nil, fmt.Errorf("album not found") + } + return albums[0], nil +} + +// GetTrackInfo fetches a single track's info +func (c *YandexClient) GetTrackInfo(ctx context.Context, trackID string) (*yandexmusic.Track, error) { + return c.client.GetTrack(ctx, trackID) +} + +// GetTrackInfoWithPosition fetches track info including track position in album +func (c *YandexClient) GetTrackInfoWithPosition(ctx context.Context, trackID string) (*yandexmusic.Track, *TrackPosition, error) { + track, err := c.client.GetTrack(ctx, trackID) + if err != nil { + return nil, nil, err + } + + // Extract track position from first album + var position *TrackPosition + if len(track.Albums) > 0 { + albumID := track.Albums[0].ID + albums, err := c.client.GetAlbumsWithTracks(ctx, []string{albumID}) + if err == nil && len(albums) > 0 { + album := albums[0] + // Find track in album.Volumes and get position + for volIdx, volume := range album.Volumes { + for trackIdx, t := range volume { + if t.ID == trackID { + position = &TrackPosition{ + Volume: volIdx + 1, + Index: trackIdx + 1, + } + break + } + } + if position != nil { + break + } + } + } + } + + return track, position, nil +} + +// GetTrackDownloadInfo gets download info for a track +func (c *YandexClient) GetTrackDownloadInfo(ctx context.Context, trackID string) ([]yandexmusic.DownloadInfo, error) { + return c.client.GetTrackDownloadInfo(ctx, trackID, "") +} + +// GetArtistInfo fetches artist info +func (c *YandexClient) GetArtistInfo(ctx context.Context, artistID int) (*yandexmusic.Artist, error) { + return c.client.GetArtist(ctx, fmt.Sprintf("%d", artistID)) +} + +// DownloadTrackXML downloads the XML with track download URLs +func (c *YandexClient) DownloadTrackXML(ctx context.Context, downloadInfoURL string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, "GET", downloadInfoURL, nil) + if err != nil { + return nil, fmt.Errorf("create download XML request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download XML request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("download XML failed: %s: %s", resp.Status, string(body)) + } + + return io.ReadAll(resp.Body) +} + +// DownloadInfoXML describes the structure of the XML file with download info +type DownloadInfoXML struct { + XMLName xml.Name `xml:"download-info"` + Host string `xml:"host"` + Path string `xml:"path"` + Ts string `xml:"ts"` + S string `xml:"s"` +} + +// GetTrackDownloadURL gets the direct MP3 download URL for a track +func (c *YandexClient) GetTrackDownloadURL(ctx context.Context, trackID string) (string, error) { + // Get download info to find best MP3 quality + downloadInfos, err := c.GetTrackDownloadInfo(ctx, trackID) + if err != nil { + return "", fmt.Errorf("get download info: %w", err) + } + + // Find best quality MP3 + var bestInfo *yandexmusic.DownloadInfo + for _, info := range downloadInfos { + if info.Codec == "mp3" && info.DirectLink { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + if bestInfo == nil { + for _, info := range downloadInfos { + if info.Codec == "mp3" { + if bestInfo == nil || info.Bitrate > bestInfo.Bitrate { + bestInfo = &info + } + } + } + } + if bestInfo == nil && len(downloadInfos) > 0 { + bestInfo = &downloadInfos[0] + } + if bestInfo == nil { + return "", fmt.Errorf("no suitable download info found") + } + + // If we have a direct URL, use it + if bestInfo.URL != "" && bestInfo.DirectLink { + return bestInfo.URL, nil + } + + // If we have a download info URL, use it to get the signed URL + if bestInfo.URL != "" { + // Download and parse XML + xmlData, err := c.DownloadTrackXML(ctx, bestInfo.URL) + if err != nil { + return "", fmt.Errorf("download XML: %w", err) + } + + var infoXML DownloadInfoXML + if err := xml.Unmarshal(xmlData, &infoXML); err != nil { + return "", fmt.Errorf("parse XML: %w", err) + } + + // Generate signed URL + signData := []byte(downloadSalt + strings.TrimPrefix(infoXML.Path, "/") + infoXML.S) + sign := md5.Sum(signData) + hexSign := hex.EncodeToString(sign[:]) + + finalURL := fmt.Sprintf("https://%s/get-mp3/%s/%s%s", infoXML.Host, hexSign, infoXML.Ts, infoXML.Path) + + if c.debug { + c.logger.Debug("constructed download URL", "url", finalURL) + } + + return finalURL, nil + } + + return "", fmt.Errorf("no download URL available") +}