Showing preview only (485K chars total). Download the full file or copy to clipboard to get everything.
Repository: Rigellute/spotify-tui
Branch: master
Commit: c4dcf6b9fd83
Files: 60
Total size: 464.3 KB
Directory structure:
gitextract_9oul2w9i/
├── .all-contributorsrc
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ └── workflows/
│ ├── cd.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── how_to_release.md
├── rustfmt.toml
├── snap/
│ ├── gui/
│ │ └── spt.desktop
│ └── snapcraft.yaml
└── src/
├── app.rs
├── banner.rs
├── cli/
│ ├── clap.rs
│ ├── cli_app.rs
│ ├── handle.rs
│ ├── mod.rs
│ └── util.rs
├── config.rs
├── event/
│ ├── events.rs
│ ├── key.rs
│ └── mod.rs
├── handlers/
│ ├── album_list.rs
│ ├── album_tracks.rs
│ ├── analysis.rs
│ ├── artist.rs
│ ├── artist_albums.rs
│ ├── artists.rs
│ ├── basic_view.rs
│ ├── common_key_events.rs
│ ├── dialog.rs
│ ├── empty.rs
│ ├── episode_table.rs
│ ├── error_screen.rs
│ ├── help_menu.rs
│ ├── home.rs
│ ├── input.rs
│ ├── library.rs
│ ├── made_for_you.rs
│ ├── mod.rs
│ ├── playbar.rs
│ ├── playlist.rs
│ ├── podcasts.rs
│ ├── recently_played.rs
│ ├── search_results.rs
│ ├── select_device.rs
│ └── track_table.rs
├── main.rs
├── network.rs
├── redirect_uri.html
├── redirect_uri.rs
├── ui/
│ ├── audio_analysis.rs
│ ├── help.rs
│ ├── mod.rs
│ └── util.rs
├── user_config.rs
└── util.rs
================================================
FILE CONTENTS
================================================
================================================
FILE: .all-contributorsrc
================================================
{
"files": [
"README.md"
],
"imageSize": 100,
"commit": false,
"contributors": [
{
"login": "Rigellute",
"name": "Alexander Keliris",
"avatar_url": "https://avatars2.githubusercontent.com/u/12150276?v=4",
"profile": "https://keliris.dev/",
"contributions": [
"code",
"doc",
"design",
"blog",
"ideas",
"infra",
"maintenance",
"platform",
"review"
]
},
{
"login": "mikepombal",
"name": "Mickael Marques",
"avatar_url": "https://avatars3.githubusercontent.com/u/6864231?v=4",
"profile": "https://github.com/mikepombal",
"contributions": [
"financial"
]
},
{
"login": "HakierGrzonzo",
"name": "Grzegorz Koperwas",
"avatar_url": "https://avatars0.githubusercontent.com/u/36668331?v=4",
"profile": "https://github.com/HakierGrzonzo",
"contributions": [
"doc"
]
},
{
"login": "amgassert",
"name": "Austin Gassert",
"avatar_url": "https://avatars2.githubusercontent.com/u/22896005?v=4",
"profile": "https://github.com/amgassert",
"contributions": [
"code"
]
},
{
"login": "calenrobinette",
"name": "Calen Robinette",
"avatar_url": "https://avatars2.githubusercontent.com/u/30757528?v=4",
"profile": "https://robinette.dev",
"contributions": [
"code"
]
},
{
"login": "MCOfficer",
"name": "M*C*O",
"avatar_url": "https://avatars0.githubusercontent.com/u/22377202?v=4",
"profile": "https://mcofficer.me",
"contributions": [
"infra"
]
},
{
"login": "eminence",
"name": "Andrew Chin",
"avatar_url": "https://avatars0.githubusercontent.com/u/402454?v=4",
"profile": "https://github.com/eminence",
"contributions": [
"code"
]
},
{
"login": "Monkeyanator",
"name": "Sam Naser",
"avatar_url": "https://avatars0.githubusercontent.com/u/4377348?v=4",
"profile": "https://www.samnaser.com/",
"contributions": [
"code"
]
},
{
"login": "radogost",
"name": "Micha",
"avatar_url": "https://avatars0.githubusercontent.com/u/15713820?v=4",
"profile": "https://github.com/radogost",
"contributions": [
"code"
]
},
{
"login": "neriglissar",
"name": "neriglissar",
"avatar_url": "https://avatars2.githubusercontent.com/u/53038761?v=4",
"profile": "https://github.com/neriglissar",
"contributions": [
"code"
]
},
{
"login": "TimonPost",
"name": "Timon",
"avatar_url": "https://avatars3.githubusercontent.com/u/19969910?v=4",
"profile": "https://github.com/TimonPost",
"contributions": [
"code"
]
},
{
"login": "echoSayonara",
"name": "echoSayonara",
"avatar_url": "https://avatars2.githubusercontent.com/u/54503126?v=4",
"profile": "https://github.com/echoSayonara",
"contributions": [
"code"
]
},
{
"login": "D-Nice",
"name": "D-Nice",
"avatar_url": "https://avatars1.githubusercontent.com/u/2888248?v=4",
"profile": "https://github.com/D-Nice",
"contributions": [
"doc",
"infra"
]
},
{
"login": "gpawlik",
"name": "Grzegorz Pawlik",
"avatar_url": "https://avatars3.githubusercontent.com/u/6296883?v=4",
"profile": "http://gpawlik.com",
"contributions": [
"code"
]
},
{
"login": "LennyPenny",
"name": "Lennart Bernhardt",
"avatar_url": "https://avatars1.githubusercontent.com/u/4027243?v=4",
"profile": "http://lenny.ninja",
"contributions": [
"code"
]
},
{
"login": "BlackYoup",
"name": "Arnaud Lefebvre",
"avatar_url": "https://avatars3.githubusercontent.com/u/6098160?v=4",
"profile": "https://github.com/BlackYoup",
"contributions": [
"code"
]
},
{
"login": "tem1029",
"name": "tem1029",
"avatar_url": "https://avatars3.githubusercontent.com/u/57712713?v=4",
"profile": "https://github.com/tem1029",
"contributions": [
"code"
]
},
{
"login": "Peterkmoss",
"name": "Peter K. Moss",
"avatar_url": "https://avatars2.githubusercontent.com/u/12544579?v=4",
"profile": "http://peter.moss.dk",
"contributions": [
"code"
]
},
{
"login": "RadicalZephyr",
"name": "Geoff Shannon",
"avatar_url": "https://avatars1.githubusercontent.com/u/113102?v=4",
"profile": "http://www.zephyrizing.net/",
"contributions": [
"code"
]
},
{
"login": "zacklukem",
"name": "Zachary Mayhew",
"avatar_url": "https://avatars0.githubusercontent.com/u/8787486?v=4",
"profile": "http://zacklukem.info",
"contributions": [
"code"
]
},
{
"login": "jfaltis",
"name": "jfaltis",
"avatar_url": "https://avatars2.githubusercontent.com/u/45465572?v=4",
"profile": "http://jfaltis.de",
"contributions": [
"code"
]
},
{
"login": "Bios-Marcel",
"name": "Marcel Schramm",
"avatar_url": "https://avatars3.githubusercontent.com/u/19377618?v=4",
"profile": "https://marcelschr.me",
"contributions": [
"doc"
]
},
{
"login": "fangyi-zhou",
"name": "Fangyi Zhou",
"avatar_url": "https://avatars3.githubusercontent.com/u/7815439?v=4",
"profile": "https://github.com/fangyi-zhou",
"contributions": [
"code"
]
},
{
"login": "synth-ruiner",
"name": "Max",
"avatar_url": "https://avatars1.githubusercontent.com/u/8642013?v=4",
"profile": "https://github.com/synth-ruiner",
"contributions": [
"code"
]
},
{
"login": "svenvNL",
"name": "Sven van der Vlist",
"avatar_url": "https://avatars1.githubusercontent.com/u/13982006?v=4",
"profile": "https://github.com/svenvNL",
"contributions": [
"code"
]
},
{
"login": "jacobchrismarsh",
"name": "jacobchrismarsh",
"avatar_url": "https://avatars2.githubusercontent.com/u/15932179?v=4",
"profile": "https://github.com/jacobchrismarsh",
"contributions": [
"code"
]
},
{
"login": "TheWalkingLeek",
"name": "Nils Rauch",
"avatar_url": "https://avatars2.githubusercontent.com/u/36076343?v=4",
"profile": "https://github.com/TheWalkingLeek",
"contributions": [
"code"
]
},
{
"login": "sputnick1124",
"name": "Nick Stockton",
"avatar_url": "https://avatars1.githubusercontent.com/u/8843309?v=4",
"profile": "https://github.com/sputnick1124",
"contributions": [
"code",
"bug",
"maintenance",
"question",
"doc"
]
},
{
"login": "stuarth",
"name": "Stuart Hinson",
"avatar_url": "https://avatars3.githubusercontent.com/u/7055?v=4",
"profile": "http://stuarth.github.io",
"contributions": [
"code"
]
},
{
"login": "samcal",
"name": "Sam Calvert",
"avatar_url": "https://avatars3.githubusercontent.com/u/2117940?v=4",
"profile": "https://github.com/samcal",
"contributions": [
"code",
"doc"
]
},
{
"login": "jwijenbergh",
"name": "Jeroen Wijenbergh",
"avatar_url": "https://avatars0.githubusercontent.com/u/46386452?v=4",
"profile": "https://github.com/jwijenbergh",
"contributions": [
"doc"
]
},
{
"login": "KimberleyCook",
"name": "Kimberley Cook",
"avatar_url": "https://avatars3.githubusercontent.com/u/2683270?v=4",
"profile": "https://twitter.com/KimberleyCook91",
"contributions": [
"doc"
]
},
{
"login": "baxtea",
"name": "Audrey Baxter",
"avatar_url": "https://avatars0.githubusercontent.com/u/22502477?v=4",
"profile": "https://github.com/baxtea",
"contributions": [
"code"
]
},
{
"login": "nkoehring",
"name": "Norman",
"avatar_url": "https://avatars2.githubusercontent.com/u/246402?v=4",
"profile": "https://koehr.in",
"contributions": [
"doc"
]
},
{
"login": "blackwolf12333",
"name": "Peter Maatman",
"avatar_url": "https://avatars0.githubusercontent.com/u/1572975?v=4",
"profile": "https://github.com/blackwolf12333",
"contributions": [
"code"
]
},
{
"login": "AlexandreSi",
"name": "AlexandreS",
"avatar_url": "https://avatars1.githubusercontent.com/u/32449369?v=4",
"profile": "https://github.com/AlexandreSi",
"contributions": [
"code"
]
},
{
"login": "fiinnnn",
"name": "Finn Vos",
"avatar_url": "https://avatars2.githubusercontent.com/u/5011796?v=4",
"profile": "https://github.com/fiinnnn",
"contributions": [
"code"
]
},
{
"login": "hurricanehrndz",
"name": "Carlos Hernandez",
"avatar_url": "https://avatars0.githubusercontent.com/u/5804237?v=4",
"profile": "https://github.com/hurricanehrndz",
"contributions": [
"platform"
]
},
{
"login": "pedrohva",
"name": "Pedro Alves",
"avatar_url": "https://avatars3.githubusercontent.com/u/33297928?v=4",
"profile": "https://github.com/pedrohva",
"contributions": [
"code"
]
},
{
"login": "jtagcat",
"name": "jtagcat",
"avatar_url": "https://avatars1.githubusercontent.com/u/38327267?v=4",
"profile": "https://gitlab.com/jtagcat/",
"contributions": [
"doc"
]
},
{
"login": "BKitor",
"name": "Benjamin Kitor",
"avatar_url": "https://avatars0.githubusercontent.com/u/16880850?v=4",
"profile": "https://github.com/BKitor",
"contributions": [
"code"
]
},
{
"login": "littleli",
"name": "Aleš Najmann",
"avatar_url": "https://avatars0.githubusercontent.com/u/544082?v=4",
"profile": "https://ales.rocks",
"contributions": [
"doc",
"platform"
]
},
{
"login": "jeremystucki",
"name": "Jeremy Stucki",
"avatar_url": "https://avatars3.githubusercontent.com/u/7629727?v=4",
"profile": "https://github.com/jeremystucki",
"contributions": [
"code"
]
},
{
"login": "pt2121",
"name": "(´⌣`ʃƪ)",
"avatar_url": "https://avatars0.githubusercontent.com/u/616399?v=4",
"profile": "http://pt2121.github.io",
"contributions": [
"code"
]
},
{
"login": "tim77",
"name": "Artem Polishchuk",
"avatar_url": "https://avatars0.githubusercontent.com/u/5614476?v=4",
"profile": "https://github.com/tim77",
"contributions": [
"platform"
]
},
{
"login": "slumber",
"name": "Chris Sosnin",
"avatar_url": "https://avatars2.githubusercontent.com/u/48099298?v=4",
"profile": "https://github.com/slumber",
"contributions": [
"code"
]
},
{
"login": "bwbuhse",
"name": "Ben Buhse",
"avatar_url": "https://avatars1.githubusercontent.com/u/21225303?v=4",
"profile": "http://www.benbuhse.com",
"contributions": [
"doc"
]
},
{
"login": "ilnaes",
"name": "Sean Li",
"avatar_url": "https://avatars1.githubusercontent.com/u/20805499?v=4",
"profile": "https://github.com/ilnaes",
"contributions": [
"code"
]
},
{
"login": "TimotheeGerber",
"name": "TimotheeGerber",
"avatar_url": "https://avatars3.githubusercontent.com/u/37541513?v=4",
"profile": "https://github.com/TimotheeGerber",
"contributions": [
"code",
"doc"
]
},
{
"login": "fratajczak",
"name": "Ferdinand Ratajczak",
"avatar_url": "https://avatars2.githubusercontent.com/u/33835579?v=4",
"profile": "https://github.com/fratajczak",
"contributions": [
"code"
]
},
{
"login": "sheelc",
"name": "Sheel Choksi",
"avatar_url": "https://avatars0.githubusercontent.com/u/1355710?v=4",
"profile": "https://github.com/sheelc",
"contributions": [
"code"
]
},
{
"login": "mhellwig",
"name": "Michael Hellwig",
"avatar_url": "https://avatars1.githubusercontent.com/u/414112?v=4",
"profile": "http://fnanp.in-ulm.de/microblog/",
"contributions": [
"doc"
]
},
{
"login": "oliver-daniel",
"name": "Oliver Daniel",
"avatar_url": "https://avatars2.githubusercontent.com/u/17235417?v=4",
"profile": "https://github.com/oliver-daniel",
"contributions": [
"code"
]
},
{
"login": "Drewsapple",
"name": "Drew Fisher",
"avatar_url": "https://avatars2.githubusercontent.com/u/4532572?v=4",
"profile": "https://github.com/Drewsapple",
"contributions": [
"code"
]
},
{
"login": "ncoder-1",
"name": "ncoder-1",
"avatar_url": "https://avatars0.githubusercontent.com/u/7622286?v=4",
"profile": "https://github.com/ncoder-1",
"contributions": [
"doc"
]
},
{
"login": "macguirerintoul",
"name": "Macguire Rintoul",
"avatar_url": "https://avatars3.githubusercontent.com/u/18323154?v=4",
"profile": "http://macguire.me",
"contributions": [
"doc"
]
},
{
"login": "RicardoHE97",
"name": "Ricardo Holguin",
"avatar_url": "https://avatars3.githubusercontent.com/u/28399979?v=4",
"profile": "http://ricardohe97.github.io",
"contributions": [
"code"
]
},
{
"login": "ksk001100",
"name": "Keisuke Toyota",
"avatar_url": "https://avatars3.githubusercontent.com/u/13160198?v=4",
"profile": "https://ksk.netlify.com",
"contributions": [
"code"
]
},
{
"login": "jackson15j",
"name": "Craig Astill",
"avatar_url": "https://avatars1.githubusercontent.com/u/3226988?v=4",
"profile": "https://jackson15j.github.io",
"contributions": [
"code"
]
},
{
"login": "onielfa",
"name": "Onielfa",
"avatar_url": "https://avatars0.githubusercontent.com/u/4358172?v=4",
"profile": "https://github.com/onielfa",
"contributions": [
"code"
]
},
{
"login": "usrme",
"name": "usrme",
"avatar_url": "https://avatars3.githubusercontent.com/u/5902545?v=4",
"profile": "https://usrme.xyz",
"contributions": [
"doc"
]
},
{
"login": "murlakatamenka",
"name": "Sergey A.",
"avatar_url": "https://avatars2.githubusercontent.com/u/7361274?v=4",
"profile": "https://github.com/murlakatamenka",
"contributions": [
"code"
]
},
{
"login": "elcih17",
"name": "Hideyuki Okada",
"avatar_url": "https://avatars3.githubusercontent.com/u/17084445?v=4",
"profile": "https://github.com/elcih17",
"contributions": [
"code"
]
},
{
"login": "kepae",
"name": "kepae",
"avatar_url": "https://avatars2.githubusercontent.com/u/4238598?v=4",
"profile": "https://github.com/kepae",
"contributions": [
"code",
"doc"
]
},
{
"login": "ericonr",
"name": "Érico Nogueira Rolim",
"avatar_url": "https://avatars0.githubusercontent.com/u/34201958?v=4",
"profile": "https://github.com/ericonr",
"contributions": [
"code"
]
},
{
"login": "BeneCollyridam",
"name": "Alexander Meinhardt Scheurer",
"avatar_url": "https://avatars2.githubusercontent.com/u/15802915?v=4",
"profile": "https://github.com/BeneCollyridam",
"contributions": [
"code"
]
},
{
"login": "Toaster192",
"name": "Ondřej Kinšt",
"avatar_url": "https://avatars0.githubusercontent.com/u/14369229?v=4",
"profile": "https://github.com/Toaster192",
"contributions": [
"code"
]
},
{
"login": "Kryan90",
"name": "Kryan90",
"avatar_url": "https://avatars3.githubusercontent.com/u/18740821?v=4",
"profile": "https://github.com/Kryan90",
"contributions": [
"doc"
]
},
{
"login": "n-ivanov",
"name": "n-ivanov",
"avatar_url": "https://avatars3.githubusercontent.com/u/11470871?v=4",
"profile": "https://github.com/n-ivanov",
"contributions": [
"code"
]
},
{
"login": "bi1yeu",
"name": "bi1yeu",
"avatar_url": "https://avatars3.githubusercontent.com/u/1185129?v=4",
"profile": "http://matthewbilyeu.com/resume/",
"contributions": [
"code",
"doc"
]
},
{
"login": "Utagai",
"name": "May",
"avatar_url": "https://avatars2.githubusercontent.com/u/10730394?v=4",
"profile": "https://github.com/Utagai",
"contributions": [
"code"
]
},
{
"login": "mucinoab",
"name": "Bruno A. Muciño",
"avatar_url": "https://avatars1.githubusercontent.com/u/28630268?v=4",
"profile": "https://mucinoab.github.io/",
"contributions": [
"code"
]
},
{
"login": "OrangeFran",
"name": "Finn Hediger",
"avatar_url": "https://avatars2.githubusercontent.com/u/55061632?v=4",
"profile": "https://github.com/OrangeFran",
"contributions": [
"code"
]
},
{
"login": "dp304",
"name": "dp304",
"avatar_url": "https://avatars1.githubusercontent.com/u/34493835?v=4",
"profile": "https://github.com/dp304",
"contributions": [
"code"
]
},
{
"login": "marcomicera",
"name": "Marco Micera",
"avatar_url": "https://avatars0.githubusercontent.com/u/13918587?v=4",
"profile": "http://marcomicera.github.io",
"contributions": [
"doc"
]
},
{
"login": "MarcoIeni",
"name": "Marco Ieni",
"avatar_url": "https://avatars3.githubusercontent.com/u/11428655?v=4",
"profile": "http://marcoieni.com",
"contributions": [
"infra"
]
},
{
"login": "ArturKovacs",
"name": "Artúr Kovács",
"avatar_url": "https://avatars3.githubusercontent.com/u/8320264?v=4",
"profile": "https://github.com/ArturKovacs",
"contributions": [
"code"
]
},
{
"login": "aokellermann",
"name": "Antony Kellermann",
"avatar_url": "https://avatars.githubusercontent.com/u/26678747?v=4",
"profile": "https://github.com/aokellermann",
"contributions": [
"code"
]
},
{
"login": "rasmuspeders1",
"name": "Rasmus Pedersen",
"avatar_url": "https://avatars.githubusercontent.com/u/1898960?v=4",
"profile": "https://github.com/rasmuspeders1",
"contributions": [
"code"
]
},
{
"login": "noir-Z",
"name": "noir-Z",
"avatar_url": "https://avatars.githubusercontent.com/u/45096516?v=4",
"profile": "https://github.com/noir-Z",
"contributions": [
"doc"
]
},
{
"login": "davidbailey00",
"name": "David Bailey",
"avatar_url": "https://avatars.githubusercontent.com/u/4248177?v=4",
"profile": "https://davidbailey.codes/",
"contributions": [
"doc"
]
},
{
"login": "sheepwall",
"name": "sheepwall",
"avatar_url": "https://avatars.githubusercontent.com/u/22132993?v=4",
"profile": "https://github.com/sheepwall",
"contributions": [
"code"
]
},
{
"login": "Hwatwasthat",
"name": "Hwatwasthat",
"avatar_url": "https://avatars.githubusercontent.com/u/29790143?v=4",
"profile": "https://github.com/Hwatwasthat",
"contributions": [
"code"
]
},
{
"login": "Jesse-Bakker",
"name": "Jesse",
"avatar_url": "https://avatars.githubusercontent.com/u/22473248?v=4",
"profile": "https://github.com/Jesse-Bakker",
"contributions": [
"code"
]
},
{
"login": "hantatsang",
"name": "Sang",
"avatar_url": "https://avatars.githubusercontent.com/u/11912225?v=4",
"profile": "https://github.com/hantatsang",
"contributions": [
"doc"
]
},
{
"login": "yktakaha4",
"name": "Yuuki Takahashi",
"avatar_url": "https://avatars.githubusercontent.com/u/20282867?v=4",
"profile": "https://yktakaha4.github.io/",
"contributions": [
"doc"
]
},
{
"login": "alejandro-angulo",
"name": "Alejandro Angulo",
"avatar_url": "https://avatars.githubusercontent.com/u/5242883?v=4",
"profile": "https://alejandr0angul0.dev/",
"contributions": [
"code"
]
},
{
"login": "masguit42",
"name": "Anton Kostin",
"avatar_url": "https://avatars.githubusercontent.com/u/11005780?v=4",
"profile": "http://t.me/lego1as",
"contributions": [
"doc"
]
},
{
"login": "JSextonn",
"name": "Justin Sexton",
"avatar_url": "https://avatars.githubusercontent.com/u/20236003?v=4",
"profile": "https://justinsexton.net",
"contributions": [
"code"
]
},
{
"login": "lejiati",
"name": "Jiati Le",
"avatar_url": "https://avatars.githubusercontent.com/u/6442124?v=4",
"profile": "https://github.com/lejiati",
"contributions": [
"doc"
]
},
{
"login": "cobbinma",
"name": "Matthew Cobbing",
"avatar_url": "https://avatars.githubusercontent.com/u/578718?v=4",
"profile": "https://github.com/cobbinma",
"contributions": [
"code"
]
},
{
"login": "Milo123459",
"name": "Milo",
"avatar_url": "https://avatars.githubusercontent.com/u/50248166?v=4",
"profile": "https://milo123459.vercel.app",
"contributions": [
"infra"
]
},
{
"login": "diegov",
"name": "Diego Veralli",
"avatar_url": "https://avatars.githubusercontent.com/u/297206?v=4",
"profile": "https://www.diegoveralli.com",
"contributions": [
"code"
]
},
{
"login": "majabojarska",
"name": "Maja Bojarska",
"avatar_url": "https://avatars.githubusercontent.com/u/33836570?v=4",
"profile": "https://github.com/majabojarska",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,
"projectName": "spotify-tui",
"projectOwner": "Rigellute",
"repoType": "github",
"repoHost": "https://github.com",
"skipCi": true
}
================================================
FILE: .github/FUNDING.yml
================================================
# These are supported funding model platforms
github: Rigellute
patreon: rigellute
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "monthly"
================================================
FILE: .github/workflows/cd.yml
================================================
name: Continuous Deployment
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
name: Publishing for ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
rust: [stable]
include:
- os: macos-latest
artifact_prefix: macos
target: x86_64-apple-darwin
binary_postfix: ""
- os: ubuntu-latest
artifact_prefix: linux
target: x86_64-unknown-linux-gnu
binary_postfix: ""
- os: windows-latest
artifact_prefix: windows
target: x86_64-pc-windows-msvc
binary_postfix: ".exe"
steps:
- name: Installing Rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ matrix.rust }}
override: true
- name: Installing needed macOS dependencies
if: matrix.os == 'macos-latest'
run: brew install openssl@1.1
- name: Installing needed Ubuntu dependencies
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
- name: Checking out sources
uses: actions/checkout@v1
- name: Running cargo build
uses: actions-rs/cargo@v1
with:
command: build
toolchain: ${{ matrix.rust }}
args: --release --target ${{ matrix.target }}
- name: Packaging final binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
BINARY_NAME=spt${{ matrix.binary_postfix }}
strip $BINARY_NAME
RELEASE_NAME=spotify-tui-${{ matrix.artifact_prefix }}
tar czvf $RELEASE_NAME.tar.gz $BINARY_NAME
if [[ ${{ runner.os }} == 'Windows' ]]; then
certutil -hashfile $RELEASE_NAME.tar.gz sha256 | grep -E [A-Fa-f0-9]{64} > $RELEASE_NAME.sha256
else
shasum -a 256 $RELEASE_NAME.tar.gz > $RELEASE_NAME.sha256
fi
- name: Releasing assets
uses: softprops/action-gh-release@v1
with:
files: |
target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.tar.gz
target/${{ matrix.target }}/release/spotify-tui-${{ matrix.artifact_prefix }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
publish-cargo:
name: Publishing to Cargo
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@master
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- run: |
sudo apt-get update
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
- uses: actions-rs/cargo@v1
with:
command: publish
args: --token ${{ secrets.CARGO_API_KEY }} --allow-dirty
================================================
FILE: .github/workflows/ci.yml
================================================
on:
pull_request:
push:
branches: master
name: Continuous Integration
jobs:
# Workaround for making Github Actions skip based on commit message `[skip ci]`
# Source https://gist.github.com/ybiquitous/c80f15c18319c63cae8447a3be341267
prepare:
runs-on: ubuntu-latest
if: |
!contains(format('{0} {1} {2}', github.event.head_commit.message, github.event.pull_request.title, github.event.pull_request.body), '[skip ci]')
steps:
- run: |
cat <<'MESSAGE'
github.event_name: ${{ toJson(github.event_name) }}
github.event:
${{ toJson(github.event) }}
MESSAGE
check:
name: Check
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@master
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
- uses: actions-rs/cargo@v1
with:
command: check
test:
name: Test Suite
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@master
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
# These dependencies are required for `clipboard`
- run: sudo apt-get install -y -qq libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
- uses: Swatinem/rust-cache@v1
- uses: actions-rs/cargo@v1
with:
command: test
fmt:
name: Rustfmt
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@master
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
components: rustfmt
- uses: Swatinem/rust-cache@v1
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@master
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
components: clippy
- uses: Swatinem/rust-cache@v1
- uses: actions-rs/cargo@v1
with:
command: clippy
args: -- -D warnings
================================================
FILE: .gitignore
================================================
/target
**/*.rs.bk
.DS_store
.env
.spotify_token_cache.json
.cached_device_id.txt
spotify-tui.sketch
spt*.txt
*.snap
.ci/lp-creds
snapcraft-login
secrets.tar
*.swp
tags
.idea
================================================
FILE: CHANGELOG.md
================================================
# Changelog
## [Unreleased]
- Fix confirmation dialog handling on playlist delete [#910](https://github.com/Rigellute/spotify-tui/pull/910)
### Added
- Show `album_type` in Search panes [#868](https://github.com/Rigellute/spotify-tui/pull/868)
- Add option to set window title to "spt - Spotify TUI" on startup [#844](https://github.com/Rigellute/spotify-tui/pull/844)
## [0.25.0] - 2021-08-24
### Fixed
- Fixed rate limiting issue [#852](https://github.com/Rigellute/spotify-tui/pull/852)
- Fix double navigation to same route [#826](https://github.com/Rigellute/spotify-tui/pull/826)
## [0.24.0] - 2021-04-26
### Fixed
- Handle invalid Client ID/Secret [#668](https://github.com/Rigellute/spotify-tui/pull/668)
- Fix default liked, shuffle, etc. icons to be more recognizable symbols [#702](https://github.com/Rigellute/spotify-tui/pull/702)
- Replace black and white default colors with reset [#742](https://github.com/Rigellute/spotify-tui/pull/742)
### Added
- Add ability to seek from the CLI [#692](https://github.com/Rigellute/spotify-tui/pull/692)
- Replace `clipboard` with `arboard` [#691](https://github.com/Rigellute/spotify-tui/pull/691)
- Implement some episode table functions [#698](https://github.com/Rigellute/spotify-tui/pull/698)
- Change `--like` that toggled the liked-state to explicit `--like` and `--dislike` flags [#717](https://github.com/Rigellute/spotify-tui/pull/717)
- Add to config: `enforce_wide_search_bar` to make search bar bigger [#738](https://github.com/Rigellute/spotify-tui/pull/738)
- Add Daily Drive to Made For You lists search [#743](https://github.com/Rigellute/spotify-tui/pull/743)
## [0.23.0] - 2021-01-06
### Fixed
- Fix app crash when pressing Enter before a screen has loaded [#599](https://github.com/Rigellute/spotify-tui/pull/599)
- Make layout more responsive to large/small screens [#502](https://github.com/Rigellute/spotify-tui/pull/502)
- Fix use of incorrect playlist index when playing from an associated track table [#632](https://github.com/Rigellute/spotify-tui/pull/632)
- Fix flickering help menu in small screens [#638](https://github.com/Rigellute/spotify-tui/pull/638)
- Optimize seek [#640](https://github.com/Rigellute/spotify-tui/pull/640)
- Fix centering of basic_view [#664](https://github.com/Rigellute/spotify-tui/pull/664)
### Added
- Implement next/previous page behavior for the Artists table [#604](https://github.com/Rigellute/spotify-tui/pull/604)
- Show saved albums when getting an artist [#612](https://github.com/Rigellute/spotify-tui/pull/612)
- Transfer playback when changing device [#408](https://github.com/Rigellute/spotify-tui/pull/408)
- Search using Spotify share URLs and URIs like the desktop client [#623](https://github.com/Rigellute/spotify-tui/pull/623)
- Make the liked icon configurable [#659](https://github.com/Rigellute/spotify-tui/pull/659)
- Add CLI for controlling Spotify [#645](https://github.com/Rigellute/spotify-tui/pull/645)
- Implement Podcasts Library page [#650](https://github.com/Rigellute/spotify-tui/pull/650)
## [0.22.0] - 2020-10-05
### Fixed
- Show ♥ next to album name in saved list [#540](https://github.com/Rigellute/spotify-tui/pull/540)
- Fix to be able to follow an artist in search result view [#565](https://github.com/Rigellute/spotify-tui/pull/565)
- Don't add analysis view to stack if already in it [#580](https://github.com/Rigellute/spotify-tui/pull/580)
### Added
- Add additional line of help to show that 'w' can be used to save/like an album [#548](https://github.com/Rigellute/spotify-tui/pull/548)
- Add handling Home and End buttons in user input [#550](https://github.com/Rigellute/spotify-tui/pull/550)
- Add `playbar_progress_text` to user config and upgrade tui lib [#564](https://github.com/Rigellute/spotify-tui/pull/564)
- Add basic playbar support for podcasts [#563](https://github.com/Rigellute/spotify-tui/pull/563)
- Add 'enable_text_emphasis' behavior config option [#573](https://github.com/Rigellute/spotify-tui/pull/573)
- Add next/prev page, jump to start/end to user config [#566](https://github.com/Rigellute/spotify-tui/pull/566)
- Add possibility to queue a song [#567](https://github.com/Rigellute/spotify-tui/pull/567)
- Add user-configurable header styling [#583](https://github.com/Rigellute/spotify-tui/pull/583)
- Show active keybindings in Help [#585](https://github.com/Rigellute/spotify-tui/pull/585)
- Full Podcast support [#581](https://github.com/Rigellute/spotify-tui/pull/581)
## [0.21.0] - 2020-07-24
### Fixed
- Fix typo in help menu [#485](https://github.com/Rigellute/spotify-tui/pull/485)
### Added
- Add save album on album view [#506](https://github.com/Rigellute/spotify-tui/pull/506)
- Add feature to like a song from basic view [#507](https://github.com/Rigellute/spotify-tui/pull/507)
- Enable Unix and Linux shortcut keys in the input [#511](https://github.com/Rigellute/spotify-tui/pull/511)
- Add album artist field to full album view [#519](https://github.com/Rigellute/spotify-tui/pull/519)
- Handle track saving in non-album contexts (eg. playlist/Made for you). [#525](https://github.com/Rigellute/spotify-tui/pull/525)
## [0.20.0] - 2020-05-28
### Fixed
- Move pagination instructions to top of help menu [#442](https://github.com/Rigellute/spotify-tui/pull/442)
### Added
- Add user configuration toggle for the loading indicator [#447](https://github.com/Rigellute/spotify-tui/pull/447)
- Add support for saving an album and following an artist in artist view [#445](https://github.com/Rigellute/spotify-tui/pull/445)
- Use the `▶` glyph to indicate the currently playing song [#472](https://github.com/Rigellute/spotify-tui/pull/472)
- Jump to play context (if available) - default binding is `o` [#474](https://github.com/Rigellute/spotify-tui/pull/474)
## [0.19.0] - 2020-05-04
### Fixed
- Fix re-authentication [#415](https://github.com/Rigellute/spotify-tui/pull/415)
- Fix audio analysis feature [#435](https://github.com/Rigellute/spotify-tui/pull/435)
### Added
- Add more readline shortcuts to the search input [#425](https://github.com/Rigellute/spotify-tui/pull/425)
## [0.18.0] - 2020-04-21
### Fixed
- Fix crash when opening playlist [#398](https://github.com/Rigellute/spotify-tui/pull/398)
- Fix crash when there are no artists avaliable [#388](https://github.com/Rigellute/spotify-tui/pull/388)
- Correctly handle playlist unfollowing [#399](https://github.com/Rigellute/spotify-tui/pull/399)
### Added
- Allow specifying alternative config file path [#391](https://github.com/Rigellute/spotify-tui/pull/391)
- List artists names in the album view [#393](https://github.com/Rigellute/spotify-tui/pull/393)
- Add confirmation modal for delete playlist action [#402](https://github.com/Rigellute/spotify-tui/pull/402)
## [0.17.1] - 2020-03-30
### Fixed
- Artist name in songs block [#365](https://github.com/Rigellute/spotify-tui/pull/365) (fixes regression)
- Add basic view key binding to help menu
## [0.17.0] - 2020-03-20
### Added
- Show if search results are liked/followed [#342](https://github.com/Rigellute/spotify-tui/pull/342)
- Show currently playing track in song search menu and play through the searched tracks [#343](https://github.com/Rigellute/spotify-tui/pull/343)
- Add a "basic view" that only shows the playbar. Press `B` to get there [#344](https://github.com/Rigellute/spotify-tui/pull/344)
- Show currently playing top track [#347](https://github.com/Rigellute/spotify-tui/pull/347)
- Press shift-s (`S`) to pick a random song on track-lists [#339](https://github.com/Rigellute/spotify-tui/pull/339)
### Fixed
- Prevent search when there is no input [#351](https://github.com/Rigellute/spotify-tui/pull/351)
## [0.16.0] - 2020-03-12
### Fixed
- Fix empty UI when pressing escape in the device and analysis views [#315](https://github.com/Rigellute/spotify-tui/pull/315)
- Fix slow and frozen UI by implementing an asynchronous runtime for network events [#322](https://github.com/Rigellute/spotify-tui/pull/322). This fixes issues #24, #92, #207 and #218. Read more [here](https://keliris.dev/improving-spotify-tui/).
## [0.15.0] - 2020-02-24
- Add experimental audio visualizer (press `v` to navigate to it). The feature uses the audio analysis data from Spotify and animates the pitch information.
- Display Artist layout when searching an artist url [#298](https://github.com/Rigellute/spotify-tui/pull/298)
- Add pagination to the help menu [#302](https://github.com/Rigellute/spotify-tui/pull/302)
## [0.14.0] - 2020-02-11
### Added
- Add high-middle-low navigation (`H`, `M`, `L` respectively) for jumping around lists [#234](https://github.com/Rigellute/spotify-tui/pull/234).
- Play every known song with `e` [#228](https://github.com/Rigellute/spotify-tui/pull/228)
- Search album by url: paste a spotify album link into the search input to go to that album [#281](https://github.com/Rigellute/spotify-tui/pull/281)
- Implement 'Made For You' section of Library [#278](https://github.com/Rigellute/spotify-tui/pull/278)
- Add user theme configuration [#284](https://github.com/Rigellute/spotify-tui/pull/284)
- Allow user to define the volume increment [#288](https://github.com/Rigellute/spotify-tui/pull/288)
### Fixed
- Fix crash on small terminals [#231](https://github.com/Rigellute/spotify-tui/pull/231)
## [0.13.0] - 2020-01-26
### Fixed
- Don't error if failed to open clipboard [#217](https://github.com/Rigellute/spotify-tui/pull/217)
- Fix scrolling beyond the end of pagination. [#216](https://github.com/Rigellute/spotify-tui/pull/216)
- Add copy album url functionality [#226](https://github.com/Rigellute/spotify-tui/pull/226)
### Added
- Allow user to configure the port for the Spotify auth Redirect URI [#204](https://github.com/Rigellute/spotify-tui/pull/204)
- Add play recommendations for song/artist on pressing 'r' [#200](https://github.com/Rigellute/spotify-tui/pull/200)
- Added continuous deployment for Windows [#222](https://github.com/Rigellute/spotify-tui/pull/222)
### Changed
- Change behavior of previous button (`p`) to mimic behavior in official Spotify client. When the track is more than three seconds in, pressing previous will restart the track. When less than three seconds it will jump to previous. [#219](https://github.com/Rigellute/spotify-tui/pull/219)
## [0.12.0] - 2020-01-23
### Added
- Add Windows support [#99](https://github.com/Rigellute/spotify-tui/pull/99)
- Add support for Related artists and top tacks [#191](https://github.com/Rigellute/spotify-tui/pull/191)
## [0.11.0] - 2019-12-23
### Added
- Add support for adding an album and following a playlist. NOTE: that this will require the user to grant more permissions [#172](https://github.com/Rigellute/spotify-tui/pull/172)
- Add shortcuts to jump to the start or the end of a playlist [#167](https://github.com/Rigellute/spotify-tui/pull/167)
- Make seeking amount configurable [#168](https://github.com/Rigellute/spotify-tui/pull/168)
### Fixed
- Fix playlist index after search [#177](https://github.com/Rigellute/spotify-tui/pull/177)
- Fix cursor offset in search input [#183](https://github.com/Rigellute/spotify-tui/pull/183)
### Changed
- Remove focus on input when jumping back [#184](https://github.com/Rigellute/spotify-tui/pull/184)
- Pad strings in status bar to prevent reformatting [#188](https://github.com/Rigellute/spotify-tui/pull/188)
## [0.10.0] - 2019-11-30
### Added
- Added pagination to user playlists [#150](https://github.com/Rigellute/spotify-tui/pull/150)
- Add ability to delete a saved album (hover over the album you wish to delete and press `D`) [#152](https://github.com/Rigellute/spotify-tui/pull/152)
- Add support for following/unfollowing artists [#155](https://github.com/Rigellute/spotify-tui/pull/155)
- Add hotkey to copy url of currently playing track (default binding is `c`)[#156](https://github.com/Rigellute/spotify-tui/pull/156)
### Fixed
- Refine Spotify result limits, which should fit your current terminal size. Most notably this will increase the number of results from a search [#154](https://github.com/Rigellute/spotify-tui/pull/154)
- Navigation from "Liked Songs" [#151](https://github.com/Rigellute/spotify-tui/pull/151)
- App hang upon trying to authenticate with Spotify on FreeBSD [#148](https://github.com/Rigellute/spotify-tui/pull/148)
- Showing "Release Date" in saved albums table [#162](https://github.com/Rigellute/spotify-tui/pull/162)
- Showing "Length" in library -> recently played table [#164](https://github.com/Rigellute/spotify-tui/pull/164)
- Typo: "AlbumTracks" -> "Albums" [#165](https://github.com/Rigellute/spotify-tui/pull/165)
- Janky volume control [#166](https://github.com/Rigellute/spotify-tui/pull/166)
- Volume bug that would prevent volumes of 0 and 100 [#170](https://github.com/Rigellute/spotify-tui/pull/170)
- Playing a wrong track in playlist [#173](https://github.com/Rigellute/spotify-tui/pull/173)
## [0.9.0] - 2019-11-13
### Added
- Add custom keybindings feature. Check the README for an example configuration [#112](https://github.com/Rigellute/spotify-tui/pull/112)
### Fixed
- Fix panic when seeking beyond track boundaries [#124](https://github.com/Rigellute/spotify-tui/pull/124)
- Add scrolling to library album list. Can now use `ctrl+d/u` to scroll between result pages [#128](https://github.com/Rigellute/spotify-tui/pull/128)
- Fix showing wrong album in library album view - [#130](https://github.com/Rigellute/spotify-tui/pull/130)
- Fix scrolling in table views [#135](https://github.com/Rigellute/spotify-tui/pull/135)
- Use space more efficiently in small terminals [#143](https://github.com/Rigellute/spotify-tui/pull/143)
## [0.8.0] - 2019-10-29
### Added
- Improve onboarding: auto fill the redirect url [#98](https://github.com/Rigellute/spotify-tui/pull/98)
- Indicate if a track is "liked" in Recently Played, Album tracks and song list views using "♥" - [#103](https://github.com/Rigellute/spotify-tui/pull/103)
- Add ability to toggle the saved state of a track: pressing `s` on an already saved track will unsave it. [#104](https://github.com/Rigellute/spotify-tui/pull/104)
- Add collaborative playlists scope. You'll need to reauthenticate due to this change. [#115](https://github.com/Rigellute/spotify-tui/pull/115)
- Add Ctrl-f and Ctrl-b emacs style keybindings for left and right motion. [#114](https://github.com/Rigellute/spotify-tui/pull/114)
### Fixed
- Fix app crash when pressing `enter`, `q` and then `down`. [#109](https://github.com/Rigellute/spotify-tui/pull/109)
- Fix trying to save a track in the album view [#119](https://github.com/Rigellute/spotify-tui/pull/119)
- Fix UI saved indicator when toggling saved track [#119](https://github.com/Rigellute/spotify-tui/pull/119)
## [0.7.0] - 2019-10-20
- Implement library "Artists" view - [#67](https://github.com/Rigellute/spotify-tui/pull/67) thanks [@svenvNL](https://github.com/svenvNL). NOTE that this adds an additional scope (`user-follow-read`), so you'll be prompted to grant this new permissions when you upgrade.
- Fix searching with non-english characters - [#30](https://github.com/Rigellute/spotify-tui/pull/30). Thanks to [@fangyi-zhou](https://github.com/fangyi-zhou)
- Remove hardcoded country (was always set to UK). We now fetch the user to get their country. [#68](https://github.com/Rigellute/spotify-tui/pull/68). Thanks to [@svenvNL](https://github.com/svenvNL)
- Save currently playing track - the playbar is now selectable/hoverable [#80](https://github.com/Rigellute/spotify-tui/pull/80)
- Lay foundation for showing if a track is saved. You can now see if the currently playing track is saved (indicated by ♥)
## [0.6.0] - 2019-10-14
### Added
- Start a web server on localhost to display a simple webpage for the Redirect URI. Should hopefully improve the onboarding experience.
- Add ability to skip to tracks using `n` for next and `p` for previous - thanks to [@samcal](https://github.com/samcal)
- Implement seek functionality - you can now use `<` to seek backwards 5 seconds and `>` to go forwards 5 seconds
- The event `A` will jump to the album list of the first artist in the track's artists list - closing [#45](https://github.com/Rigellute/spotify-tui/issues/45)
- Add volume controls - use `-` to decrease and `+` to increase volume in 10% increments. Closes [#57](https://github.com/Rigellute/spotify-tui/issues/57)
### Fixed
- Keep format of highlighted track when it is playing - [#44](https://github.com/Rigellute/spotify-tui/pull/44) thanks to [@jfaltis](https://github.com/jfaltis)
- Search input bug: Fix "out-of-bounds" crash when pressing left too many times [#63](https://github.com/Rigellute/spotify-tui/issues/63)
- Search input bug: Fix issue that backspace always deleted the end of input, not where the cursor was [#33](https://github.com/Rigellute/spotify-tui/issues/33)
## [0.5.0] - 2019-10-11
### Added
- Add `Ctrl-r` to cycle repeat mode ([@baxtea](https://github.com/baxtea))
- Refresh token when token expires ([@fangyi-zhou](https://github.com/fangyi-zhou))
- Upgrade `rspotify` to fix [#39](https://github.com/Rigellute/spotify-tui/issues/39) ([@epwalsh](https://github.com/epwalsh))
### Changed
- Fix duplicate albums showing in artist discographies ([@baxtea](https://github.com/baxtea))
- Slightly better error message with some debug tips when tracks fail to play
## [0.4.0] - 2019-10-05
### Added
- Can now install `spotify-tui` using `brew reinstall Rigellute/tap/spotify-tui` and `cargo install spotify-tui` 🎉
- Credentials (auth token, chosen device, and CLIENT_ID & CLIENT_SECRET) are now all stored in the same place (`${HOME}/.config/spotify-tui/client.yml`), which closes [this issue](https://github.com/Rigellute/spotify-tui/issues/4)
## [0.3.0] - 2019-10-04
### Added
- Improved onboarding experience
- On first startup instructions will (hopefully) guide the user on how to get setup
## [0.2.0] - 2019-09-17
### Added
- General navigation improvements
- Improved search input: it should now behave how one would expect
- Add `Ctrl-d/u` for scrolling up and down through result pages (currently only implemented for "Liked Songs")
- Minor theme improvements
- Make tables responsive
- Implement resume playback feature
- Add saved albums table
- Show which track is currently playing within a table or list
- Add `a` event to jump to currently playing track's album
- Add `s` event to save a track from within the "Recently Played" view (eventually this should be everywhere)
- Add `Ctrl-s` to toggle shuffle
- Add the following journey: search -> select artist and see their albums -> select album -> go to album and play tracks
# What is this?
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
================================================
FILE: Cargo.toml
================================================
[package]
name = "spotify-tui"
description = "A terminal user interface for Spotify"
homepage = "https://github.com/Rigellute/spotify-tui"
documentation = "https://github.com/Rigellute/spotify-tui"
repository = "https://github.com/Rigellute/spotify-tui"
keywords = ["spotify", "tui", "cli", "terminal"]
categories = ["command-line-utilities"]
version = "0.25.0"
authors = ["Alexander Keliris <rigellute@gmail.com>"]
edition = "2018"
license = "MIT OR Apache-2.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
rspotify = "0.10.0"
tui = { version = "0.16.0", features = ["crossterm"], default-features = false }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "0.8"
dirs = "3.0.2"
clap = "2.33.3"
unicode-width = "0.1.8"
backtrace = "0.3.57"
arboard = "1.2.0"
crossterm = "0.20"
tokio = { version = "0.2", features = ["full"] }
rand = "0.8.4"
anyhow = "1.0.43"
[[bin]]
bench = false
path = "src/main.rs"
name = "spt"
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Alexander Keliris
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# Spotify TUI



[](https://crates.io/crates/spotify-tui)

<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[](https://twitter.com/intent/follow?screen_name=AlexKeliris)
A Spotify client for the terminal written in Rust.

The terminal in the demo above is using the [Rigel theme](https://rigel.netlify.com/).
- [Spotify TUI](#spotify-tui)
- [Installation](#installation)
- [Homebrew](#homebrew)
- [Snap](#snap)
- [AUR](#aur)
- [Nix](#nix)
- [Void Linux](#void-linux)
- [Fedora/CentOS](#fedora-centos)
- [Cargo](#cargo)
- [Note on Linux](#note-on-linux)
- [Windows](#windows-10)
- [Scoop installer](#scoop-installer)
- [Manual](#manual)
- [Connecting to Spotify’s API](#connecting-to-spotifys-api)
- [Usage](#usage)
- [Configuration](#configuration)
- [Limitations](#limitations)
- [Using with spotifyd](#using-with-spotifyd)
- [Libraries used](#libraries-used)
- [Development](#development)
- [Windows Subsystem for Linux](#windows-subsystem-for-linux)
- [Contributors](#contributors)
- [Roadmap](#roadmap)
- [High-level requirements yet to be implemented](#high-level-requirements-yet-to-be-implemented)
## Installation
The binary executable is `spt`.
### Homebrew
For both macOS and Linux
```bash
brew install spotify-tui
```
To update, run
```bash
brew upgrade spotify-tui
```
### Snap
For a system with Snap installed, run
```bash
snap install spt
```
The stable version will be installed for you automatically.
If you want to install the nightly build, run
```bash
snap install spt --edge
```
### AUR
For those on Arch Linux you can find the package on AUR [here](https://aur.archlinux.org/packages/spotify-tui/). If however you're using an AUR helper you can install directly from that, for example (in the case of [yay](https://github.com/Jguer/yay)), run
```bash
yay -S spotify-tui
```
### Nix
Available as the package `spotify-tui`. To install run:
```bash
nix-env -iA nixpkgs.spotify-tui
```
Where `nixpkgs` is the channel name in your configuration. For a more up-to-date installation, use the unstable channel.
It is also possible to add the package to `environment.systemPackages` (for NixOS), or `home.packages` when using [home-manager](https://github.com/rycee/home-manager).
### Void Linux
Available on the official repositories. To install, run
```bash
sudo xbps-install -Su spotify-tui
```
### Fedora/CentOS
Available on the [Copr](https://copr.fedorainfracloud.org/coprs/atim/spotify-tui/) repositories. To install, run
```bash
sudo dnf copr enable atim/spotify-tui -y && sudo dnf install spotify-tui
```
### Cargo
Use this option if your architecture is not supported by the pre-built binaries found on the [releases page](https://github.com/Rigellute/spotify-tui/releases).
First, install [Rust](https://www.rust-lang.org/tools/install) (using the recommended `rustup` installation method) and then
```bash
cargo install spotify-tui
```
This method will build the binary from source.
To update, run the same command again.
#### Note on Linux
For compilation on Linux the development packages for `libssl` are required.
For basic installation instructions, see [install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic).
In order to locate dependencies, the compilation also requires `pkg-config` to be installed.
If you are using the Windows Subsystem for Linux, you'll need to [install additional dependencies](#windows-subsystem-for-linux).
### Windows 10
#### Scoop installer
First, make sure scoop installer is on your windows box, for instruction please visit [scoop.sh](https://scoop.sh)
Then open powershell and run following two commands:
```bash
scoop bucket add scoop-bucket https://github.com/Rigellute/scoop-bucket
scoop install spotify-tui
```
After that program is available as: `spt` or `spt.exe`
### Manual
1. Download the latest [binary](https://github.com/Rigellute/spotify-tui/releases) for your OS.
1. `cd` to the file you just downloaded and unzip
1. `cd` to `spotify-tui` and run with `./spt`
## Connecting to Spotify’s API
`spotify-tui` needs to connect to Spotify’s API in order to find music by
name, play tracks etc.
Instructions on how to set this up will be shown when you first run the app.
But here they are again:
1. Go to the [Spotify dashboard](https://developer.spotify.com/dashboard/applications)
1. Click `Create an app`
- You now can see your `Client ID` and `Client Secret`
1. Now click `Edit Settings`
1. Add `http://localhost:8888/callback` to the Redirect URIs
1. Scroll down and click `Save`
1. You are now ready to authenticate with Spotify!
1. Go back to the terminal
1. Run `spt`
1. Enter your `Client ID`
1. Enter your `Client Secret`
1. Press enter to confirm the default port (8888) or enter a custom port
1. You will be redirected to an official Spotify webpage to ask you for permissions.
1. After accepting the permissions, you'll be redirected to localhost. If all goes well, the redirect URL will be parsed automatically and now you're done. If the local webserver fails for some reason you'll be redirected to a blank webpage that might say something like "Connection Refused" since no server is running. Regardless, copy the URL and paste into the prompt in the terminal.
And now you are ready to use the `spotify-tui` 🎉
You can edit the config at anytime at `${HOME}/.config/spotify-tui/client.yml`. (for snap `${HOME}/snap/spt/current/.config/spotify-tui/client.yml`)
## Usage
The binary is named `spt`.
Running `spt` with no arguments will bring up the UI. Press `?` to bring up a help menu that shows currently implemented key events and their actions.
There is also a CLI that is able to do most of the stuff the UI does. Use `spt --help` to learn more.
Here are some example to get you excited.
```
spt --completions zsh # Prints shell completions for zsh to stdout (bash, power-shell and more are supported)
spt play --name "Your Playlist" --playlist --random # Plays a random song from "Your Playlist"
spt play --name "A cool song" --track # Plays 'A cool song'
spt playback --like --shuffle # Likes the current song and toggles shuffle mode
spt playback --toggle # Plays/pauses the current playback
spt list --liked --limit 50 # See your liked songs (50 is the max limit)
# Looks for 'An even cooler song' and gives you the '{name} from {album}' of up to 30 matches
spt search "An even cooler song" --tracks --format "%t from %b" --limit 30
```
# Configuration
A configuration file is located at `${HOME}/.config/spotify-tui/config.yml`, for snap `${HOME}/snap/spt/current/.config/spotify-tui/config.yml`
(not to be confused with client.yml which handles spotify authentication)
The following is a sample config.yml file:
```yaml
# Sample config file
# The theme colours can be an rgb string of the form "255, 255, 255" or a string that references the colours from your terminal theme: Reset, Black, Red, Green, Yellow, Blue, Magenta, Cyan, Gray, DarkGray, LightRed, LightGreen, LightYellow, LightBlue, LightMagenta, LightCyan, White.
theme:
active: Cyan # current playing song in list
banner: LightCyan # the "spotify-tui" banner on launch
error_border: Red # error dialog border
error_text: LightRed # error message text (e.g. "Spotify API reported error 404")
hint: Yellow # hint text in errors
hovered: Magenta # hovered pane border
inactive: Gray # borders of inactive panes
playbar_background: Black # background of progress bar
playbar_progress: LightCyan # filled-in part of the progress bar
playbar_progress_text: Cyan # song length and time played/left indicator in the progress bar
playbar_text: White # artist name in player pane
selected: LightCyan # a) selected pane border, b) hovered item in list, & c) track title in player
text: "255, 255, 255" # text in panes
header: White # header text in panes (e.g. 'Title', 'Artist', etc.)
behavior:
seek_milliseconds: 5000
volume_increment: 10
# The lower the number the higher the "frames per second". You can decrease this number so that the audio visualisation is smoother but this can be expensive!
tick_rate_milliseconds: 250
# Enable text emphasis (typically italic/bold text styling). Disabling this might be important if the terminal config is otherwise restricted and rendering text escapes interferes with the UI.
enable_text_emphasis: true
# Controls whether to show a loading indicator in the top right of the UI whenever communicating with Spotify API
show_loading_indicator: true
# Disables the responsive layout that makes the search bar smaller on bigger
# screens and enforces a wide search bar
enforce_wide_search_bar: false
# Determines the text icon to display next to "liked" Spotify items, such as
# liked songs and albums, or followed artists. Can be any length string.
# These icons require a patched nerd font.
liked_icon: ♥
shuffle_icon: 🔀
repeat_track_icon: 🔂
repeat_context_icon: 🔁
playing_icon: ▶
paused_icon: ⏸
# Sets the window title to "spt - Spotify TUI" via ANSI escape code.
set_window_title: true
keybindings:
# Key stroke can be used if it only uses two keys:
# ctrl-q works,
# ctrl-alt-q doesn't.
back: "ctrl-q"
jump_to_album: "a"
# Shift modifiers use a capital letter (also applies with other modifier keys
# like ctrl-A)
jump_to_artist_album: "A"
manage_devices: "d"
decrease_volume: "-"
increase_volume: "+"
toggle_playback: " "
seek_backwards: "<"
seek_forwards: ">"
next_track: "n"
previous_track: "p"
copy_song_url: "c"
copy_album_url: "C"
help: "?"
shuffle: "ctrl-s"
repeat: "r"
search: "/"
audio_analysis: "v"
jump_to_context: "o"
basic_view: "B"
add_item_to_queue: "z"
```
## Limitations
This app uses the [Web API](https://developer.spotify.com/documentation/web-api/) from Spotify, which doesn't handle streaming itself. So you'll need either an official Spotify client open or a lighter weight alternative such as [spotifyd](https://github.com/Spotifyd/spotifyd).
If you want to play tracks, Spotify requires that you have a Premium account.
## Using with [spotifyd](https://github.com/Spotifyd/spotifyd)
Follow the spotifyd documentation to get set up.
After that there is not much to it.
1. Start running the spotifyd daemon.
1. Start up `spt`
1. Press `d` to go to the device selection menu and the spotifyd "device" should be there - if not check [these docs](https://github.com/Spotifyd/spotifyd#logging)
## Libraries used
- [tui-rs](https://github.com/fdehau/tui-rs)
- [rspotify](https://github.com/ramsayleung/rspotify)
## Development
1. [Install OpenSSL](https://docs.rs/openssl/0.10.25/openssl/#automatic)
1. [Install Rust](https://www.rust-lang.org/tools/install)
1. [Install `xorg-dev`](https://github.com/aweinstock314/rust-clipboard#prerequisites) (required for clipboard support)
1. Clone or fork this repo and `cd` to it
1. And then `cargo run`
### Windows Subsystem for Linux
You might get a linking error. If so, you'll probably need to install additional dependencies required by the clipboard package
```bash
sudo apt-get install -y -qq pkg-config libssl-dev libxcb1-dev libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev
```
## Contributors
Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<table>
<tr>
<td align="center"><a href="https://keliris.dev/"><img src="https://avatars2.githubusercontent.com/u/12150276?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexander Keliris</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Rigellute" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=Rigellute" title="Documentation">📖</a> <a href="#design-Rigellute" title="Design">🎨</a> <a href="#blog-Rigellute" title="Blogposts">📝</a> <a href="#ideas-Rigellute" title="Ideas, Planning, & Feedback">🤔</a> <a href="#infra-Rigellute" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#maintenance-Rigellute" title="Maintenance">🚧</a> <a href="#platform-Rigellute" title="Packaging/porting to new platform">📦</a> <a href="https://github.com/Rigellute/spotify-tui/pulls?q=is%3Apr+reviewed-by%3ARigellute" title="Reviewed Pull Requests">👀</a></td>
<td align="center"><a href="https://github.com/mikepombal"><img src="https://avatars3.githubusercontent.com/u/6864231?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Mickael Marques</b></sub></a><br /><a href="#financial-mikepombal" title="Financial">💵</a></td>
<td align="center"><a href="https://github.com/HakierGrzonzo"><img src="https://avatars0.githubusercontent.com/u/36668331?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Grzegorz Koperwas</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=HakierGrzonzo" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/amgassert"><img src="https://avatars2.githubusercontent.com/u/22896005?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Austin Gassert</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=amgassert" title="Code">💻</a></td>
<td align="center"><a href="https://robinette.dev"><img src="https://avatars2.githubusercontent.com/u/30757528?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Calen Robinette</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=calenrobinette" title="Code">💻</a></td>
<td align="center"><a href="https://mcofficer.me"><img src="https://avatars0.githubusercontent.com/u/22377202?v=4?s=100" width="100px;" alt=""/><br /><sub><b>M*C*O</b></sub></a><br /><a href="#infra-MCOfficer" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/eminence"><img src="https://avatars0.githubusercontent.com/u/402454?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Andrew Chin</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=eminence" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://www.samnaser.com/"><img src="https://avatars0.githubusercontent.com/u/4377348?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sam Naser</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Monkeyanator" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/radogost"><img src="https://avatars0.githubusercontent.com/u/15713820?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Micha</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=radogost" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/neriglissar"><img src="https://avatars2.githubusercontent.com/u/53038761?v=4?s=100" width="100px;" alt=""/><br /><sub><b>neriglissar</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=neriglissar" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/TimonPost"><img src="https://avatars3.githubusercontent.com/u/19969910?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Timon</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=TimonPost" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/echoSayonara"><img src="https://avatars2.githubusercontent.com/u/54503126?v=4?s=100" width="100px;" alt=""/><br /><sub><b>echoSayonara</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=echoSayonara" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/D-Nice"><img src="https://avatars1.githubusercontent.com/u/2888248?v=4?s=100" width="100px;" alt=""/><br /><sub><b>D-Nice</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=D-Nice" title="Documentation">📖</a> <a href="#infra-D-Nice" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="http://gpawlik.com"><img src="https://avatars3.githubusercontent.com/u/6296883?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Grzegorz Pawlik</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=gpawlik" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="http://lenny.ninja"><img src="https://avatars1.githubusercontent.com/u/4027243?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Lennart Bernhardt</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=LennyPenny" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/BlackYoup"><img src="https://avatars3.githubusercontent.com/u/6098160?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Arnaud Lefebvre</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=BlackYoup" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/tem1029"><img src="https://avatars3.githubusercontent.com/u/57712713?v=4?s=100" width="100px;" alt=""/><br /><sub><b>tem1029</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=tem1029" title="Code">💻</a></td>
<td align="center"><a href="http://peter.moss.dk"><img src="https://avatars2.githubusercontent.com/u/12544579?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter K. Moss</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Peterkmoss" title="Code">💻</a></td>
<td align="center"><a href="http://www.zephyrizing.net/"><img src="https://avatars1.githubusercontent.com/u/113102?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Geoff Shannon</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=RadicalZephyr" title="Code">💻</a></td>
<td align="center"><a href="http://zacklukem.info"><img src="https://avatars0.githubusercontent.com/u/8787486?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Zachary Mayhew</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=zacklukem" title="Code">💻</a></td>
<td align="center"><a href="http://jfaltis.de"><img src="https://avatars2.githubusercontent.com/u/45465572?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jfaltis</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jfaltis" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://marcelschr.me"><img src="https://avatars3.githubusercontent.com/u/19377618?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marcel Schramm</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Bios-Marcel" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/fangyi-zhou"><img src="https://avatars3.githubusercontent.com/u/7815439?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Fangyi Zhou</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=fangyi-zhou" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/synth-ruiner"><img src="https://avatars1.githubusercontent.com/u/8642013?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Max</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=synth-ruiner" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/svenvNL"><img src="https://avatars1.githubusercontent.com/u/13982006?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sven van der Vlist</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=svenvNL" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/jacobchrismarsh"><img src="https://avatars2.githubusercontent.com/u/15932179?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jacobchrismarsh</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jacobchrismarsh" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/TheWalkingLeek"><img src="https://avatars2.githubusercontent.com/u/36076343?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nils Rauch</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=TheWalkingLeek" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/sputnick1124"><img src="https://avatars1.githubusercontent.com/u/8843309?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Nick Stockton</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=sputnick1124" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/issues?q=author%3Asputnick1124" title="Bug reports">🐛</a> <a href="#maintenance-sputnick1124" title="Maintenance">🚧</a> <a href="#question-sputnick1124" title="Answering Questions">💬</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=sputnick1124" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="http://stuarth.github.io"><img src="https://avatars3.githubusercontent.com/u/7055?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Stuart Hinson</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=stuarth" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/samcal"><img src="https://avatars3.githubusercontent.com/u/2117940?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sam Calvert</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=samcal" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=samcal" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/jwijenbergh"><img src="https://avatars0.githubusercontent.com/u/46386452?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jeroen Wijenbergh</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jwijenbergh" title="Documentation">📖</a></td>
<td align="center"><a href="https://twitter.com/KimberleyCook91"><img src="https://avatars3.githubusercontent.com/u/2683270?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kimberley Cook</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=KimberleyCook" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/baxtea"><img src="https://avatars0.githubusercontent.com/u/22502477?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Audrey Baxter</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=baxtea" title="Code">💻</a></td>
<td align="center"><a href="https://koehr.in"><img src="https://avatars2.githubusercontent.com/u/246402?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Norman</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=nkoehring" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/blackwolf12333"><img src="https://avatars0.githubusercontent.com/u/1572975?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Peter Maatman</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=blackwolf12333" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/AlexandreSi"><img src="https://avatars1.githubusercontent.com/u/32449369?v=4?s=100" width="100px;" alt=""/><br /><sub><b>AlexandreS</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=AlexandreSi" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/fiinnnn"><img src="https://avatars2.githubusercontent.com/u/5011796?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Finn Vos</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=fiinnnn" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/hurricanehrndz"><img src="https://avatars0.githubusercontent.com/u/5804237?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Carlos Hernandez</b></sub></a><br /><a href="#platform-hurricanehrndz" title="Packaging/porting to new platform">📦</a></td>
<td align="center"><a href="https://github.com/pedrohva"><img src="https://avatars3.githubusercontent.com/u/33297928?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Pedro Alves</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=pedrohva" title="Code">💻</a></td>
<td align="center"><a href="https://gitlab.com/jtagcat/"><img src="https://avatars1.githubusercontent.com/u/38327267?v=4?s=100" width="100px;" alt=""/><br /><sub><b>jtagcat</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jtagcat" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/BKitor"><img src="https://avatars0.githubusercontent.com/u/16880850?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Benjamin Kitor</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=BKitor" title="Code">💻</a></td>
<td align="center"><a href="https://ales.rocks"><img src="https://avatars0.githubusercontent.com/u/544082?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Aleš Najmann</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=littleli" title="Documentation">📖</a> <a href="#platform-littleli" title="Packaging/porting to new platform">📦</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/jeremystucki"><img src="https://avatars3.githubusercontent.com/u/7629727?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jeremy Stucki</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jeremystucki" title="Code">💻</a></td>
<td align="center"><a href="http://pt2121.github.io"><img src="https://avatars0.githubusercontent.com/u/616399?v=4?s=100" width="100px;" alt=""/><br /><sub><b>(´⌣`ʃƪ)</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=pt2121" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/tim77"><img src="https://avatars0.githubusercontent.com/u/5614476?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Artem Polishchuk</b></sub></a><br /><a href="#platform-tim77" title="Packaging/porting to new platform">📦</a></td>
<td align="center"><a href="https://github.com/slumber"><img src="https://avatars2.githubusercontent.com/u/48099298?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Chris Sosnin</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=slumber" title="Code">💻</a></td>
<td align="center"><a href="http://www.benbuhse.com"><img src="https://avatars1.githubusercontent.com/u/21225303?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ben Buhse</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=bwbuhse" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/ilnaes"><img src="https://avatars1.githubusercontent.com/u/20805499?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sean Li</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=ilnaes" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/TimotheeGerber"><img src="https://avatars3.githubusercontent.com/u/37541513?v=4?s=100" width="100px;" alt=""/><br /><sub><b>TimotheeGerber</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=TimotheeGerber" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=TimotheeGerber" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/fratajczak"><img src="https://avatars2.githubusercontent.com/u/33835579?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ferdinand Ratajczak</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=fratajczak" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/sheelc"><img src="https://avatars0.githubusercontent.com/u/1355710?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sheel Choksi</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=sheelc" title="Code">💻</a></td>
<td align="center"><a href="http://fnanp.in-ulm.de/microblog/"><img src="https://avatars1.githubusercontent.com/u/414112?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Michael Hellwig</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=mhellwig" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/oliver-daniel"><img src="https://avatars2.githubusercontent.com/u/17235417?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Oliver Daniel</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=oliver-daniel" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Drewsapple"><img src="https://avatars2.githubusercontent.com/u/4532572?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Drew Fisher</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Drewsapple" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/ncoder-1"><img src="https://avatars0.githubusercontent.com/u/7622286?v=4?s=100" width="100px;" alt=""/><br /><sub><b>ncoder-1</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=ncoder-1" title="Documentation">📖</a></td>
<td align="center"><a href="http://macguire.me"><img src="https://avatars3.githubusercontent.com/u/18323154?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Macguire Rintoul</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=macguirerintoul" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="http://ricardohe97.github.io"><img src="https://avatars3.githubusercontent.com/u/28399979?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ricardo Holguin</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=RicardoHE97" title="Code">💻</a></td>
<td align="center"><a href="https://ksk.netlify.com"><img src="https://avatars3.githubusercontent.com/u/13160198?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Keisuke Toyota</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=ksk001100" title="Code">💻</a></td>
<td align="center"><a href="https://jackson15j.github.io"><img src="https://avatars1.githubusercontent.com/u/3226988?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Craig Astill</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=jackson15j" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/onielfa"><img src="https://avatars0.githubusercontent.com/u/4358172?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Onielfa</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=onielfa" title="Code">💻</a></td>
<td align="center"><a href="https://usrme.xyz"><img src="https://avatars3.githubusercontent.com/u/5902545?v=4?s=100" width="100px;" alt=""/><br /><sub><b>usrme</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=usrme" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/murlakatamenka"><img src="https://avatars2.githubusercontent.com/u/7361274?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sergey A.</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=murlakatamenka" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/elcih17"><img src="https://avatars3.githubusercontent.com/u/17084445?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Hideyuki Okada</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=elcih17" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/kepae"><img src="https://avatars2.githubusercontent.com/u/4238598?v=4?s=100" width="100px;" alt=""/><br /><sub><b>kepae</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=kepae" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=kepae" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/ericonr"><img src="https://avatars0.githubusercontent.com/u/34201958?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Érico Nogueira Rolim</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=ericonr" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/BeneCollyridam"><img src="https://avatars2.githubusercontent.com/u/15802915?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alexander Meinhardt Scheurer</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=BeneCollyridam" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Toaster192"><img src="https://avatars0.githubusercontent.com/u/14369229?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Ondřej Kinšt</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Toaster192" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Kryan90"><img src="https://avatars3.githubusercontent.com/u/18740821?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Kryan90</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Kryan90" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/n-ivanov"><img src="https://avatars3.githubusercontent.com/u/11470871?v=4?s=100" width="100px;" alt=""/><br /><sub><b>n-ivanov</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=n-ivanov" title="Code">💻</a></td>
<td align="center"><a href="http://matthewbilyeu.com/resume/"><img src="https://avatars3.githubusercontent.com/u/1185129?v=4?s=100" width="100px;" alt=""/><br /><sub><b>bi1yeu</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=bi1yeu" title="Code">💻</a> <a href="https://github.com/Rigellute/spotify-tui/commits?author=bi1yeu" title="Documentation">📖</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/Utagai"><img src="https://avatars2.githubusercontent.com/u/10730394?v=4?s=100" width="100px;" alt=""/><br /><sub><b>May</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Utagai" title="Code">💻</a></td>
<td align="center"><a href="https://mucinoab.github.io/"><img src="https://avatars1.githubusercontent.com/u/28630268?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Bruno A. Muciño</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=mucinoab" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/OrangeFran"><img src="https://avatars2.githubusercontent.com/u/55061632?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Finn Hediger</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=OrangeFran" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/dp304"><img src="https://avatars1.githubusercontent.com/u/34493835?v=4?s=100" width="100px;" alt=""/><br /><sub><b>dp304</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=dp304" title="Code">💻</a></td>
<td align="center"><a href="http://marcomicera.github.io"><img src="https://avatars0.githubusercontent.com/u/13918587?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marco Micera</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=marcomicera" title="Documentation">📖</a></td>
<td align="center"><a href="http://marcoieni.com"><img src="https://avatars3.githubusercontent.com/u/11428655?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Marco Ieni</b></sub></a><br /><a href="#infra-MarcoIeni" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://github.com/ArturKovacs"><img src="https://avatars3.githubusercontent.com/u/8320264?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Artúr Kovács</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=ArturKovacs" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/aokellermann"><img src="https://avatars.githubusercontent.com/u/26678747?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Antony Kellermann</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=aokellermann" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/rasmuspeders1"><img src="https://avatars.githubusercontent.com/u/1898960?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Rasmus Pedersen</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=rasmuspeders1" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/noir-Z"><img src="https://avatars.githubusercontent.com/u/45096516?v=4?s=100" width="100px;" alt=""/><br /><sub><b>noir-Z</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=noir-Z" title="Documentation">📖</a></td>
<td align="center"><a href="https://davidbailey.codes/"><img src="https://avatars.githubusercontent.com/u/4248177?v=4?s=100" width="100px;" alt=""/><br /><sub><b>David Bailey</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=davidbailey00" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/sheepwall"><img src="https://avatars.githubusercontent.com/u/22132993?v=4?s=100" width="100px;" alt=""/><br /><sub><b>sheepwall</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=sheepwall" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Hwatwasthat"><img src="https://avatars.githubusercontent.com/u/29790143?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Hwatwasthat</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Hwatwasthat" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/Jesse-Bakker"><img src="https://avatars.githubusercontent.com/u/22473248?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jesse</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=Jesse-Bakker" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://github.com/hantatsang"><img src="https://avatars.githubusercontent.com/u/11912225?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Sang</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=hantatsang" title="Documentation">📖</a></td>
<td align="center"><a href="https://yktakaha4.github.io/"><img src="https://avatars.githubusercontent.com/u/20282867?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Yuuki Takahashi</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=yktakaha4" title="Documentation">📖</a></td>
<td align="center"><a href="https://alejandr0angul0.dev/"><img src="https://avatars.githubusercontent.com/u/5242883?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Alejandro Angulo</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=alejandro-angulo" title="Code">💻</a></td>
<td align="center"><a href="http://t.me/lego1as"><img src="https://avatars.githubusercontent.com/u/11005780?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Anton Kostin</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=masguit42" title="Documentation">📖</a></td>
<td align="center"><a href="https://justinsexton.net"><img src="https://avatars.githubusercontent.com/u/20236003?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Justin Sexton</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=JSextonn" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/lejiati"><img src="https://avatars.githubusercontent.com/u/6442124?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Jiati Le</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=lejiati" title="Documentation">📖</a></td>
<td align="center"><a href="https://github.com/cobbinma"><img src="https://avatars.githubusercontent.com/u/578718?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Matthew Cobbing</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=cobbinma" title="Code">💻</a></td>
</tr>
<tr>
<td align="center"><a href="https://milo123459.vercel.app"><img src="https://avatars.githubusercontent.com/u/50248166?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Milo</b></sub></a><br /><a href="#infra-Milo123459" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a></td>
<td align="center"><a href="https://www.diegoveralli.com"><img src="https://avatars.githubusercontent.com/u/297206?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Diego Veralli</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=diegov" title="Code">💻</a></td>
<td align="center"><a href="https://github.com/majabojarska"><img src="https://avatars.githubusercontent.com/u/33836570?v=4?s=100" width="100px;" alt=""/><br /><sub><b>Maja Bojarska</b></sub></a><br /><a href="https://github.com/Rigellute/spotify-tui/commits?author=majabojarska" title="Code">💻</a></td>
</tr>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
## Roadmap
The goal is to eventually implement almost every Spotify feature.
### High-level requirements yet to be implemented
- Add songs to a playlist
- Be able to scroll through result pages in every view
This table shows all that is possible with the Spotify API, what is implemented already, and whether that is essential.
| API method | Implemented yet? | Explanation | Essential? |
| ------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------- |
| track | No | returns a single track given the track's ID, URI or URL | No |
| tracks | No | returns a list of tracks given a list of track IDs, URIs, or URLs | No |
| artist | No | returns a single artist given the artist's ID, URI or URL | Yes |
| artists | No | returns a list of artists given the artist IDs, URIs, or URLs | No |
| artist_albums | Yes | Get Spotify catalog information about an artist's albums | Yes |
| artist_top_tracks | Yes | Get Spotify catalog information about an artist's top 10 tracks by country. | Yes |
| artist_related_artists | Yes | Get Spotify catalog information about artists similar to an identified artist. Similarity is based on analysis of the Spotify community's listening history. | Yes |
| album | Yes | returns a single album given the album's ID, URIs or URL | Yes |
| albums | No | returns a list of albums given the album IDs, URIs, or URLs | No |
| search_album | Yes | Search album based on query | Yes |
| search_artist | Yes | Search artist based on query | Yes |
| search_track | Yes | Search track based on query | Yes |
| search_playlist | Yes | Search playlist based on query | Yes |
| album_track | Yes | Get Spotify catalog information about an album's tracks | Yes |
| user | No | Gets basic profile information about a Spotify User | No |
| playlist | Yes | Get full details about Spotify playlist | Yes |
| current_user_playlists | Yes | Get current user playlists without required getting his profile | Yes |
| user_playlists | No | Gets playlists of a user | No |
| user_playlist | No | Gets playlist of a user | No |
| user_playlist_tracks | Yes | Get full details of the tracks of a playlist owned by a user | Yes |
| user_playlist_create | No | Creates a playlist for a user | Yes |
| user_playlist_change_detail | No | Changes a playlist's name and/or public/private state | Yes |
| user_playlist_unfollow | Yes | Unfollows (deletes) a playlist for a user | Yes |
| user_playlist_add_track | No | Adds tracks to a playlist | Yes |
| user_playlist_replace_track | No | Replace all tracks in a playlist | No |
| user_playlist_recorder_tracks | No | Reorder tracks in a playlist | No |
| user_playlist_remove_all_occurrences_of_track | No | Removes all occurrences of the given tracks from the given playlist | No |
| user_playlist_remove_specific_occurrenes_of_track | No | Removes all occurrences of the given tracks from the given playlist | No |
| user_playlist_follow_playlist | Yes | Add the current authenticated user as a follower of a playlist. | Yes |
| user_playlist_check_follow | No | Check to see if the given users are following the given playlist | Yes |
| me | No | Get detailed profile information about the current user. | Yes |
| current_user | No | Alias for `me` | Yes |
| current_user_playing_track | Yes | Get information about the current users currently playing track. | Yes |
| current_user_saved_albums | Yes | Gets a list of the albums saved in the current authorized user's "Your Music" library | Yes |
| current_user_saved_tracks | Yes | Gets the user's saved tracks or "Liked Songs" | Yes |
| current_user_followed_artists | Yes | Gets a list of the artists followed by the current authorized user | Yes |
| current_user_saved_tracks_delete | Yes | Remove one or more tracks from the current user's "Your Music" library. | Yes |
| current_user_saved_tracks_contain | No | Check if one or more tracks is already saved in the current Spotify user’s “Your Music” library. | Yes |
| current_user_saved_tracks_add | Yes | Save one or more tracks to the current user's "Your Music" library. | Yes |
| current_user_top_artists | No | Get the current user's top artists | Yes |
| current_user_top_tracks | No | Get the current user's top tracks | Yes |
| current_user_recently_played | Yes | Get the current user's recently played tracks | Yes |
| current_user_saved_albums_add | Yes | Add one or more albums to the current user's "Your Music" library. | Yes |
| current_user_saved_albums_delete | Yes | Remove one or more albums from the current user's "Your Music" library. | Yes |
| user_follow_artists | Yes | Follow one or more artists | Yes |
| user_unfollow_artists | Yes | Unfollow one or more artists | Yes |
| user_follow_users | No | Follow one or more users | No |
| user_unfollow_users | No | Unfollow one or more users | No |
| featured_playlists | No | Get a list of Spotify featured playlists | Yes |
| new_releases | No | Get a list of new album releases featured in Spotify | Yes |
| categories | No | Get a list of categories used to tag items in Spotify | Yes |
| recommendations | Yes | Get Recommendations Based on Seeds | Yes |
| audio_features | No | Get audio features for a track | No |
| audios_features | No | Get Audio Features for Several Tracks | No |
| audio_analysis | Yes | Get Audio Analysis for a Track | Yes |
| device | Yes | Get a User’s Available Devices | Yes |
| current_playback | Yes | Get Information About The User’s Current Playback | Yes |
| current_playing | No | Get the User’s Currently Playing Track | No |
| transfer_playback | Yes | Transfer a User’s Playback | Yes |
| start_playback | Yes | Start/Resume a User’s Playback | Yes |
| pause_playback | Yes | Pause a User’s Playback | Yes |
| next_track | Yes | Skip User’s Playback To Next Track | Yes |
| previous_track | Yes | Skip User’s Playback To Previous Track | Yes |
| seek_track | Yes | Seek To Position In Currently Playing Track | Yes |
| repeat | Yes | Set Repeat Mode On User’s Playback | Yes |
| volume | Yes | Set Volume For User’s Playback | Yes |
| shuffle | Yes | Toggle Shuffle For User’s Playback | Yes |
================================================
FILE: how_to_release.md
================================================
# To create a release
The releases are automated via GitHub actions, using [this configuration file](https://github.com/Rigellute/spotify-tui/blob/master/.github/workflows/cd.yml).
The release is triggered by pushing a tag.
1. Bump the version in `Cargo.toml` and run the app to update the `lock` file
1. Update the "Unreleased" header for the new version in the `CHANGELOG`. Use `### Added/Fixed/Changed` headers as appropriate
1. Commit the changes and push them.
1. Create a new tag e.g. `git tag -a v0.7.0` and add the CHANGELOG to the commit body
1. Push the tag `git push --tags`
1. Wait for the build to finish on the [Actions page](https://github.com/Rigellute/spotify-tui/actions)
1. This should publish to cargo as well
### Update `brew`
1. `cd` to the [`tap` repo](https://github.com/Rigellute/homebrew-tap)
1. Run script to update the Formula `sh scripts/spotify-tui.sh $VERSION`
### Update `scoop` (Windows 10)
1. `cd` to [the `scoop` repo](https://github.com/Rigellute/scoop-bucket)
1. Run the script to update the manifest `sh scripts/spotify-tui.sh $VERSION`
================================================
FILE: rustfmt.toml
================================================
tab_spaces=2
edition = "2018"
================================================
FILE: snap/gui/spt.desktop
================================================
[Desktop Entry]
Name=Spotify TUI
Exec=spt
Comment=Spotify for the terminal written in Rust
Icon=${SNAP}/meta/gui/spt.png
Type=Application
Terminal=true
StartupNotify=false
Categories=Music;
================================================
FILE: snap/snapcraft.yaml
================================================
name: spt-temp
base: core18
adopt-info: spotify-tui
summary: Spotify TUI
description: |
Spotify for the terminal written in Rust
grade: stable
confinement: strict
parts:
spotify-tui:
plugin: rust
source: .
build-packages:
- libssl-dev
- pkg-config
- libxcb1-dev
- libxcb-render0-dev
- libxcb-shape0-dev
- libxcb-xfixes0-dev
stage-packages:
- libxau6
- libxcb-render0
- libxcb-shape0
- libxcb-xfixes0
- libxcb1
- libxdmcp6
override-pull: |
snapcraftctl pull
last_committed_tag="$(git describe --tags --abbrev=0)"
echo $last_committed_tag
VERSION="$(git describe --first-parent --tags --always)"
echo "Setting version to $VERSION"
snapcraftctl set-version "${VERSION}"
apps:
spt-temp:
environment:
LD_LIBRARY_PATH: $SNAP/usr/lib/$SNAPCRAFT_ARCH_TRIPLET/pulseaudio:$LD_LIBRARY_PATH
command: spt
plugs:
- desktop
- network
- network-bind
- pulseaudio
================================================
FILE: src/app.rs
================================================
use super::user_config::UserConfig;
use crate::network::IoEvent;
use anyhow::anyhow;
use rspotify::{
model::{
album::{FullAlbum, SavedAlbum, SimplifiedAlbum},
artist::FullArtist,
audio::AudioAnalysis,
context::CurrentlyPlaybackContext,
device::DevicePayload,
page::{CursorBasedPage, Page},
playing::PlayHistory,
playlist::{PlaylistTrack, SimplifiedPlaylist},
show::{FullShow, Show, SimplifiedEpisode, SimplifiedShow},
track::{FullTrack, SavedTrack, SimplifiedTrack},
user::PrivateUser,
PlayingItem,
},
senum::Country,
};
use std::str::FromStr;
use std::sync::mpsc::Sender;
use std::{
cmp::{max, min},
collections::HashSet,
time::{Instant, SystemTime},
};
use tui::layout::Rect;
use arboard::Clipboard;
pub const LIBRARY_OPTIONS: [&str; 6] = [
"Made For You",
"Recently Played",
"Liked Songs",
"Albums",
"Artists",
"Podcasts",
];
const DEFAULT_ROUTE: Route = Route {
id: RouteId::Home,
active_block: ActiveBlock::Empty,
hovered_block: ActiveBlock::Library,
};
#[derive(Clone)]
pub struct ScrollableResultPages<T> {
index: usize,
pub pages: Vec<T>,
}
impl<T> ScrollableResultPages<T> {
pub fn new() -> ScrollableResultPages<T> {
ScrollableResultPages {
index: 0,
pages: vec![],
}
}
pub fn get_results(&self, at_index: Option<usize>) -> Option<&T> {
self.pages.get(at_index.unwrap_or(self.index))
}
pub fn get_mut_results(&mut self, at_index: Option<usize>) -> Option<&mut T> {
self.pages.get_mut(at_index.unwrap_or(self.index))
}
pub fn add_pages(&mut self, new_pages: T) {
self.pages.push(new_pages);
// Whenever a new page is added, set the active index to the end of the vector
self.index = self.pages.len() - 1;
}
}
#[derive(Default)]
pub struct SpotifyResultAndSelectedIndex<T> {
pub index: usize,
pub result: T,
}
#[derive(Clone)]
pub struct Library {
pub selected_index: usize,
pub saved_tracks: ScrollableResultPages<Page<SavedTrack>>,
pub made_for_you_playlists: ScrollableResultPages<Page<SimplifiedPlaylist>>,
pub saved_albums: ScrollableResultPages<Page<SavedAlbum>>,
pub saved_shows: ScrollableResultPages<Page<Show>>,
pub saved_artists: ScrollableResultPages<CursorBasedPage<FullArtist>>,
pub show_episodes: ScrollableResultPages<Page<SimplifiedEpisode>>,
}
#[derive(PartialEq, Debug)]
pub enum SearchResultBlock {
AlbumSearch,
SongSearch,
ArtistSearch,
PlaylistSearch,
ShowSearch,
Empty,
}
#[derive(PartialEq, Debug, Clone)]
pub enum ArtistBlock {
TopTracks,
Albums,
RelatedArtists,
Empty,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum DialogContext {
PlaylistWindow,
PlaylistSearch,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum ActiveBlock {
Analysis,
PlayBar,
AlbumTracks,
AlbumList,
ArtistBlock,
Empty,
Error,
HelpMenu,
Home,
Input,
Library,
MyPlaylists,
Podcasts,
EpisodeTable,
RecentlyPlayed,
SearchResultBlock,
SelectDevice,
TrackTable,
MadeForYou,
Artists,
BasicView,
Dialog(DialogContext),
}
#[derive(Clone, PartialEq, Debug)]
pub enum RouteId {
Analysis,
AlbumTracks,
AlbumList,
Artist,
BasicView,
Error,
Home,
RecentlyPlayed,
Search,
SelectedDevice,
TrackTable,
MadeForYou,
Artists,
Podcasts,
PodcastEpisodes,
Recommendations,
Dialog,
}
#[derive(Debug)]
pub struct Route {
pub id: RouteId,
pub active_block: ActiveBlock,
pub hovered_block: ActiveBlock,
}
// Is it possible to compose enums?
#[derive(PartialEq, Debug)]
pub enum TrackTableContext {
MyPlaylists,
AlbumSearch,
PlaylistSearch,
SavedTracks,
RecommendedTracks,
MadeForYou,
}
// Is it possible to compose enums?
#[derive(Clone, PartialEq, Debug, Copy)]
pub enum AlbumTableContext {
Simplified,
Full,
}
#[derive(Clone, PartialEq, Debug, Copy)]
pub enum EpisodeTableContext {
Simplified,
Full,
}
#[derive(Clone, PartialEq, Debug)]
pub enum RecommendationsContext {
Artist,
Song,
}
pub struct SearchResult {
pub albums: Option<Page<SimplifiedAlbum>>,
pub artists: Option<Page<FullArtist>>,
pub playlists: Option<Page<SimplifiedPlaylist>>,
pub tracks: Option<Page<FullTrack>>,
pub shows: Option<Page<SimplifiedShow>>,
pub selected_album_index: Option<usize>,
pub selected_artists_index: Option<usize>,
pub selected_playlists_index: Option<usize>,
pub selected_tracks_index: Option<usize>,
pub selected_shows_index: Option<usize>,
pub hovered_block: SearchResultBlock,
pub selected_block: SearchResultBlock,
}
#[derive(Default)]
pub struct TrackTable {
pub tracks: Vec<FullTrack>,
pub selected_index: usize,
pub context: Option<TrackTableContext>,
}
#[derive(Clone)]
pub struct SelectedShow {
pub show: SimplifiedShow,
}
#[derive(Clone)]
pub struct SelectedFullShow {
pub show: FullShow,
}
#[derive(Clone)]
pub struct SelectedAlbum {
pub album: SimplifiedAlbum,
pub tracks: Page<SimplifiedTrack>,
pub selected_index: usize,
}
#[derive(Clone)]
pub struct SelectedFullAlbum {
pub album: FullAlbum,
pub selected_index: usize,
}
#[derive(Clone)]
pub struct Artist {
pub artist_name: String,
pub albums: Page<SimplifiedAlbum>,
pub related_artists: Vec<FullArtist>,
pub top_tracks: Vec<FullTrack>,
pub selected_album_index: usize,
pub selected_related_artist_index: usize,
pub selected_top_track_index: usize,
pub artist_hovered_block: ArtistBlock,
pub artist_selected_block: ArtistBlock,
}
pub struct App {
pub instant_since_last_current_playback_poll: Instant,
navigation_stack: Vec<Route>,
pub audio_analysis: Option<AudioAnalysis>,
pub home_scroll: u16,
pub user_config: UserConfig,
pub artists: Vec<FullArtist>,
pub artist: Option<Artist>,
pub album_table_context: AlbumTableContext,
pub saved_album_tracks_index: usize,
pub api_error: String,
pub current_playback_context: Option<CurrentlyPlaybackContext>,
pub devices: Option<DevicePayload>,
// Inputs:
// input is the string for input;
// input_idx is the index of the cursor in terms of character;
// input_cursor_position is the sum of the width of characters preceding the cursor.
// Reason for this complication is due to non-ASCII characters, they may
// take more than 1 bytes to store and more than 1 character width to display.
pub input: Vec<char>,
pub input_idx: usize,
pub input_cursor_position: u16,
pub liked_song_ids_set: HashSet<String>,
pub followed_artist_ids_set: HashSet<String>,
pub saved_album_ids_set: HashSet<String>,
pub saved_show_ids_set: HashSet<String>,
pub large_search_limit: u32,
pub library: Library,
pub playlist_offset: u32,
pub made_for_you_offset: u32,
pub playlist_tracks: Option<Page<PlaylistTrack>>,
pub made_for_you_tracks: Option<Page<PlaylistTrack>>,
pub playlists: Option<Page<SimplifiedPlaylist>>,
pub recently_played: SpotifyResultAndSelectedIndex<Option<CursorBasedPage<PlayHistory>>>,
pub recommended_tracks: Vec<FullTrack>,
pub recommendations_seed: String,
pub recommendations_context: Option<RecommendationsContext>,
pub search_results: SearchResult,
pub selected_album_simplified: Option<SelectedAlbum>,
pub selected_album_full: Option<SelectedFullAlbum>,
pub selected_device_index: Option<usize>,
pub selected_playlist_index: Option<usize>,
pub active_playlist_index: Option<usize>,
pub size: Rect,
pub small_search_limit: u32,
pub song_progress_ms: u128,
pub seek_ms: Option<u128>,
pub track_table: TrackTable,
pub episode_table_context: EpisodeTableContext,
pub selected_show_simplified: Option<SelectedShow>,
pub selected_show_full: Option<SelectedFullShow>,
pub user: Option<PrivateUser>,
pub album_list_index: usize,
pub made_for_you_index: usize,
pub artists_list_index: usize,
pub clipboard: Option<Clipboard>,
pub shows_list_index: usize,
pub episode_list_index: usize,
pub help_docs_size: u32,
pub help_menu_page: u32,
pub help_menu_max_lines: u32,
pub help_menu_offset: u32,
pub is_loading: bool,
io_tx: Option<Sender<IoEvent>>,
pub is_fetching_current_playback: bool,
pub spotify_token_expiry: SystemTime,
pub dialog: Option<String>,
pub confirm: bool,
}
impl Default for App {
fn default() -> Self {
App {
audio_analysis: None,
album_table_context: AlbumTableContext::Full,
album_list_index: 0,
made_for_you_index: 0,
artists_list_index: 0,
shows_list_index: 0,
episode_list_index: 0,
artists: vec![],
artist: None,
user_config: UserConfig::new(),
saved_album_tracks_index: 0,
recently_played: Default::default(),
size: Rect::default(),
selected_album_simplified: None,
selected_album_full: None,
home_scroll: 0,
library: Library {
saved_tracks: ScrollableResultPages::new(),
made_for_you_playlists: ScrollableResultPages::new(),
saved_albums: ScrollableResultPages::new(),
saved_shows: ScrollableResultPages::new(),
saved_artists: ScrollableResultPages::new(),
show_episodes: ScrollableResultPages::new(),
selected_index: 0,
},
liked_song_ids_set: HashSet::new(),
followed_artist_ids_set: HashSet::new(),
saved_album_ids_set: HashSet::new(),
saved_show_ids_set: HashSet::new(),
navigation_stack: vec![DEFAULT_ROUTE],
large_search_limit: 20,
small_search_limit: 4,
api_error: String::new(),
current_playback_context: None,
devices: None,
input: vec![],
input_idx: 0,
input_cursor_position: 0,
playlist_offset: 0,
made_for_you_offset: 0,
playlist_tracks: None,
made_for_you_tracks: None,
playlists: None,
recommended_tracks: vec![],
recommendations_context: None,
recommendations_seed: "".to_string(),
search_results: SearchResult {
hovered_block: SearchResultBlock::SongSearch,
selected_block: SearchResultBlock::Empty,
albums: None,
artists: None,
playlists: None,
shows: None,
selected_album_index: None,
selected_artists_index: None,
selected_playlists_index: None,
selected_tracks_index: None,
selected_shows_index: None,
tracks: None,
},
song_progress_ms: 0,
seek_ms: None,
selected_device_index: None,
selected_playlist_index: None,
active_playlist_index: None,
track_table: Default::default(),
episode_table_context: EpisodeTableContext::Full,
selected_show_simplified: None,
selected_show_full: None,
user: None,
instant_since_last_current_playback_poll: Instant::now(),
clipboard: Clipboard::new().ok(),
help_docs_size: 0,
help_menu_page: 0,
help_menu_max_lines: 0,
help_menu_offset: 0,
is_loading: false,
io_tx: None,
is_fetching_current_playback: false,
spotify_token_expiry: SystemTime::now(),
dialog: None,
confirm: false,
}
}
}
impl App {
pub fn new(
io_tx: Sender<IoEvent>,
user_config: UserConfig,
spotify_token_expiry: SystemTime,
) -> App {
App {
io_tx: Some(io_tx),
user_config,
spotify_token_expiry,
..App::default()
}
}
// Send a network event to the network thread
pub fn dispatch(&mut self, action: IoEvent) {
// `is_loading` will be set to false again after the async action has finished in network.rs
self.is_loading = true;
if let Some(io_tx) = &self.io_tx {
if let Err(e) = io_tx.send(action) {
self.is_loading = false;
println!("Error from dispatch {}", e);
// TODO: handle error
};
}
}
fn apply_seek(&mut self, seek_ms: u32) {
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
let duration_ms = match item {
PlayingItem::Track(track) => track.duration_ms,
PlayingItem::Episode(episode) => episode.duration_ms,
};
let event = if seek_ms < duration_ms {
IoEvent::Seek(seek_ms)
} else {
IoEvent::NextTrack
};
self.dispatch(event);
}
}
fn poll_current_playback(&mut self) {
// Poll every 5 seconds
let poll_interval_ms = 5_000;
let elapsed = self
.instant_since_last_current_playback_poll
.elapsed()
.as_millis();
if !self.is_fetching_current_playback && elapsed >= poll_interval_ms {
self.is_fetching_current_playback = true;
// Trigger the seek if the user has set a new position
match self.seek_ms {
Some(seek_ms) => self.apply_seek(seek_ms as u32),
None => self.dispatch(IoEvent::GetCurrentPlayback),
}
}
}
pub fn update_on_tick(&mut self) {
self.poll_current_playback();
if let Some(CurrentlyPlaybackContext {
item: Some(item),
progress_ms: Some(progress_ms),
is_playing,
..
}) = &self.current_playback_context
{
// Update progress even when the song is not playing,
// because seeking is possible while paused
let elapsed = if *is_playing {
self
.instant_since_last_current_playback_poll
.elapsed()
.as_millis()
} else {
0u128
} + u128::from(*progress_ms);
let duration_ms = match item {
PlayingItem::Track(track) => track.duration_ms,
PlayingItem::Episode(episode) => episode.duration_ms,
};
if elapsed < u128::from(duration_ms) {
self.song_progress_ms = elapsed;
} else {
self.song_progress_ms = duration_ms.into();
}
}
}
pub fn seek_forwards(&mut self) {
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
let duration_ms = match item {
PlayingItem::Track(track) => track.duration_ms,
PlayingItem::Episode(episode) => episode.duration_ms,
};
let old_progress = match self.seek_ms {
Some(seek_ms) => seek_ms,
None => self.song_progress_ms,
};
let new_progress = min(
old_progress as u32 + self.user_config.behavior.seek_milliseconds,
duration_ms,
);
self.seek_ms = Some(new_progress as u128);
}
}
pub fn seek_backwards(&mut self) {
let old_progress = match self.seek_ms {
Some(seek_ms) => seek_ms,
None => self.song_progress_ms,
};
let new_progress = if old_progress as u32 > self.user_config.behavior.seek_milliseconds {
old_progress as u32 - self.user_config.behavior.seek_milliseconds
} else {
0u32
};
self.seek_ms = Some(new_progress as u128);
}
pub fn get_recommendations_for_seed(
&mut self,
seed_artists: Option<Vec<String>>,
seed_tracks: Option<Vec<String>>,
first_track: Option<FullTrack>,
) {
let user_country = self.get_user_country();
self.dispatch(IoEvent::GetRecommendationsForSeed(
seed_artists,
seed_tracks,
Box::new(first_track),
user_country,
));
}
pub fn get_recommendations_for_track_id(&mut self, id: String) {
let user_country = self.get_user_country();
self.dispatch(IoEvent::GetRecommendationsForTrackId(id, user_country));
}
pub fn increase_volume(&mut self) {
if let Some(context) = self.current_playback_context.clone() {
let current_volume = context.device.volume_percent as u8;
let next_volume = min(
current_volume + self.user_config.behavior.volume_increment,
100,
);
if next_volume != current_volume {
self.dispatch(IoEvent::ChangeVolume(next_volume));
}
}
}
pub fn decrease_volume(&mut self) {
if let Some(context) = self.current_playback_context.clone() {
let current_volume = context.device.volume_percent as i8;
let next_volume = max(
current_volume - self.user_config.behavior.volume_increment as i8,
0,
);
if next_volume != current_volume {
self.dispatch(IoEvent::ChangeVolume(next_volume as u8));
}
}
}
pub fn handle_error(&mut self, e: anyhow::Error) {
self.push_navigation_stack(RouteId::Error, ActiveBlock::Error);
self.api_error = e.to_string();
}
pub fn toggle_playback(&mut self) {
if let Some(CurrentlyPlaybackContext {
is_playing: true, ..
}) = &self.current_playback_context
{
self.dispatch(IoEvent::PausePlayback);
} else {
// When no offset or uris are passed, spotify will resume current playback
self.dispatch(IoEvent::StartPlayback(None, None, None));
}
}
pub fn previous_track(&mut self) {
if self.song_progress_ms >= 3_000 {
self.dispatch(IoEvent::Seek(0));
} else {
self.dispatch(IoEvent::PreviousTrack);
}
}
// The navigation_stack actually only controls the large block to the right of `library` and
// `playlists`
pub fn push_navigation_stack(&mut self, next_route_id: RouteId, next_active_block: ActiveBlock) {
if !self
.navigation_stack
.last()
.map(|last_route| last_route.id == next_route_id)
.unwrap_or(false)
{
self.navigation_stack.push(Route {
id: next_route_id,
active_block: next_active_block,
hovered_block: next_active_block,
});
}
}
pub fn pop_navigation_stack(&mut self) -> Option<Route> {
if self.navigation_stack.len() == 1 {
None
} else {
self.navigation_stack.pop()
}
}
pub fn get_current_route(&self) -> &Route {
// if for some reason there is no route return the default
self.navigation_stack.last().unwrap_or(&DEFAULT_ROUTE)
}
fn get_current_route_mut(&mut self) -> &mut Route {
self.navigation_stack.last_mut().unwrap()
}
pub fn set_current_route_state(
&mut self,
active_block: Option<ActiveBlock>,
hovered_block: Option<ActiveBlock>,
) {
let mut current_route = self.get_current_route_mut();
if let Some(active_block) = active_block {
current_route.active_block = active_block;
}
if let Some(hovered_block) = hovered_block {
current_route.hovered_block = hovered_block;
}
}
pub fn copy_song_url(&mut self) {
let clipboard = match &mut self.clipboard {
Some(ctx) => ctx,
None => return,
};
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
match item {
PlayingItem::Track(track) => {
if let Err(e) = clipboard.set_text(format!(
"https://open.spotify.com/track/{}",
track.id.to_owned().unwrap_or_default()
)) {
self.handle_error(anyhow!("failed to set clipboard content: {}", e));
}
}
PlayingItem::Episode(episode) => {
if let Err(e) = clipboard.set_text(format!(
"https://open.spotify.com/episode/{}",
episode.id.to_owned()
)) {
self.handle_error(anyhow!("failed to set clipboard content: {}", e));
}
}
}
}
}
pub fn copy_album_url(&mut self) {
let clipboard = match &mut self.clipboard {
Some(ctx) => ctx,
None => return,
};
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
match item {
PlayingItem::Track(track) => {
if let Err(e) = clipboard.set_text(format!(
"https://open.spotify.com/album/{}",
track.album.id.to_owned().unwrap_or_default()
)) {
self.handle_error(anyhow!("failed to set clipboard content: {}", e));
}
}
PlayingItem::Episode(episode) => {
if let Err(e) = clipboard.set_text(format!(
"https://open.spotify.com/show/{}",
episode.show.id.to_owned()
)) {
self.handle_error(anyhow!("failed to set clipboard content: {}", e));
}
}
}
}
}
pub fn set_saved_tracks_to_table(&mut self, saved_track_page: &Page<SavedTrack>) {
self.dispatch(IoEvent::SetTracksToTable(
saved_track_page
.items
.clone()
.into_iter()
.map(|item| item.track)
.collect::<Vec<FullTrack>>(),
));
}
pub fn set_saved_artists_to_table(&mut self, saved_artists_page: &CursorBasedPage<FullArtist>) {
self.dispatch(IoEvent::SetArtistsToTable(
saved_artists_page
.items
.clone()
.into_iter()
.collect::<Vec<FullArtist>>(),
))
}
pub fn get_current_user_saved_artists_next(&mut self) {
match self
.library
.saved_artists
.get_results(Some(self.library.saved_artists.index + 1))
.cloned()
{
Some(saved_artists) => {
self.set_saved_artists_to_table(&saved_artists);
self.library.saved_artists.index += 1
}
None => {
if let Some(saved_artists) = &self.library.saved_artists.clone().get_results(None) {
if let Some(last_artist) = saved_artists.items.last() {
self.dispatch(IoEvent::GetFollowedArtists(Some(last_artist.id.clone())));
}
}
}
}
}
pub fn get_current_user_saved_artists_previous(&mut self) {
if self.library.saved_artists.index > 0 {
self.library.saved_artists.index -= 1;
}
if let Some(saved_artists) = &self.library.saved_artists.get_results(None).cloned() {
self.set_saved_artists_to_table(saved_artists);
}
}
pub fn get_current_user_saved_tracks_next(&mut self) {
// Before fetching the next tracks, check if we have already fetched them
match self
.library
.saved_tracks
.get_results(Some(self.library.saved_tracks.index + 1))
.cloned()
{
Some(saved_tracks) => {
self.set_saved_tracks_to_table(&saved_tracks);
self.library.saved_tracks.index += 1
}
None => {
if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None) {
let offset = Some(saved_tracks.offset + saved_tracks.limit);
self.dispatch(IoEvent::GetCurrentSavedTracks(offset));
}
}
}
}
pub fn get_current_user_saved_tracks_previous(&mut self) {
if self.library.saved_tracks.index > 0 {
self.library.saved_tracks.index -= 1;
}
if let Some(saved_tracks) = &self.library.saved_tracks.get_results(None).cloned() {
self.set_saved_tracks_to_table(saved_tracks);
}
}
pub fn shuffle(&mut self) {
if let Some(context) = &self.current_playback_context.clone() {
self.dispatch(IoEvent::Shuffle(context.shuffle_state));
};
}
pub fn get_current_user_saved_albums_next(&mut self) {
match self
.library
.saved_albums
.get_results(Some(self.library.saved_albums.index + 1))
.cloned()
{
Some(_) => self.library.saved_albums.index += 1,
None => {
if let Some(saved_albums) = &self.library.saved_albums.get_results(None) {
let offset = Some(saved_albums.offset + saved_albums.limit);
self.dispatch(IoEvent::GetCurrentUserSavedAlbums(offset));
}
}
}
}
pub fn get_current_user_saved_albums_previous(&mut self) {
if self.library.saved_albums.index > 0 {
self.library.saved_albums.index -= 1;
}
}
pub fn current_user_saved_album_delete(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::SearchResultBlock => {
if let Some(albums) = &self.search_results.albums {
if let Some(selected_index) = self.search_results.selected_album_index {
let selected_album = &albums.items[selected_index];
if let Some(album_id) = selected_album.id.clone() {
self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));
}
}
}
}
ActiveBlock::AlbumList => {
if let Some(albums) = self.library.saved_albums.get_results(None) {
if let Some(selected_album) = albums.items.get(self.album_list_index) {
let album_id = selected_album.album.id.clone();
self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));
}
}
}
ActiveBlock::ArtistBlock => {
if let Some(artist) = &self.artist {
if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) {
if let Some(album_id) = selected_album.id.clone() {
self.dispatch(IoEvent::CurrentUserSavedAlbumDelete(album_id));
}
}
}
}
_ => (),
}
}
pub fn current_user_saved_album_add(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::SearchResultBlock => {
if let Some(albums) = &self.search_results.albums {
if let Some(selected_index) = self.search_results.selected_album_index {
let selected_album = &albums.items[selected_index];
if let Some(album_id) = selected_album.id.clone() {
self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id));
}
}
}
}
ActiveBlock::ArtistBlock => {
if let Some(artist) = &self.artist {
if let Some(selected_album) = artist.albums.items.get(artist.selected_album_index) {
if let Some(album_id) = selected_album.id.clone() {
self.dispatch(IoEvent::CurrentUserSavedAlbumAdd(album_id));
}
}
}
}
_ => (),
}
}
pub fn get_current_user_saved_shows_next(&mut self) {
match self
.library
.saved_shows
.get_results(Some(self.library.saved_shows.index + 1))
.cloned()
{
Some(_) => self.library.saved_shows.index += 1,
None => {
if let Some(saved_shows) = &self.library.saved_shows.get_results(None) {
let offset = Some(saved_shows.offset + saved_shows.limit);
self.dispatch(IoEvent::GetCurrentUserSavedShows(offset));
}
}
}
}
pub fn get_current_user_saved_shows_previous(&mut self) {
if self.library.saved_shows.index > 0 {
self.library.saved_shows.index -= 1;
}
}
pub fn get_episode_table_next(&mut self, show_id: String) {
match self
.library
.show_episodes
.get_results(Some(self.library.show_episodes.index + 1))
.cloned()
{
Some(_) => self.library.show_episodes.index += 1,
None => {
if let Some(show_episodes) = &self.library.show_episodes.get_results(None) {
let offset = Some(show_episodes.offset + show_episodes.limit);
self.dispatch(IoEvent::GetCurrentShowEpisodes(show_id, offset));
}
}
}
}
pub fn get_episode_table_previous(&mut self) {
if self.library.show_episodes.index > 0 {
self.library.show_episodes.index -= 1;
}
}
pub fn user_unfollow_artists(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::SearchResultBlock => {
if let Some(artists) = &self.search_results.artists {
if let Some(selected_index) = self.search_results.selected_artists_index {
let selected_artist: &FullArtist = &artists.items[selected_index];
let artist_id = selected_artist.id.clone();
self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));
}
}
}
ActiveBlock::AlbumList => {
if let Some(artists) = self.library.saved_artists.get_results(None) {
if let Some(selected_artist) = artists.items.get(self.artists_list_index) {
let artist_id = selected_artist.id.clone();
self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));
}
}
}
ActiveBlock::ArtistBlock => {
if let Some(artist) = &self.artist {
let selected_artis = &artist.related_artists[artist.selected_related_artist_index];
let artist_id = selected_artis.id.clone();
self.dispatch(IoEvent::UserUnfollowArtists(vec![artist_id]));
}
}
_ => (),
};
}
pub fn user_follow_artists(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::SearchResultBlock => {
if let Some(artists) = &self.search_results.artists {
if let Some(selected_index) = self.search_results.selected_artists_index {
let selected_artist: &FullArtist = &artists.items[selected_index];
let artist_id = selected_artist.id.clone();
self.dispatch(IoEvent::UserFollowArtists(vec![artist_id]));
}
}
}
ActiveBlock::ArtistBlock => {
if let Some(artist) = &self.artist {
let selected_artis = &artist.related_artists[artist.selected_related_artist_index];
let artist_id = selected_artis.id.clone();
self.dispatch(IoEvent::UserFollowArtists(vec![artist_id]));
}
}
_ => (),
}
}
pub fn user_follow_playlist(&mut self) {
if let SearchResult {
playlists: Some(ref playlists),
selected_playlists_index: Some(selected_index),
..
} = self.search_results
{
let selected_playlist: &SimplifiedPlaylist = &playlists.items[selected_index];
let selected_id = selected_playlist.id.clone();
let selected_public = selected_playlist.public;
let selected_owner_id = selected_playlist.owner.id.clone();
self.dispatch(IoEvent::UserFollowPlaylist(
selected_owner_id,
selected_id,
selected_public,
));
}
}
pub fn user_unfollow_playlist(&mut self) {
if let (Some(playlists), Some(selected_index), Some(user)) =
(&self.playlists, self.selected_playlist_index, &self.user)
{
let selected_playlist = &playlists.items[selected_index];
let selected_id = selected_playlist.id.clone();
let user_id = user.id.clone();
self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id))
}
}
pub fn user_unfollow_playlist_search_result(&mut self) {
if let (Some(playlists), Some(selected_index), Some(user)) = (
&self.search_results.playlists,
self.search_results.selected_playlists_index,
&self.user,
) {
let selected_playlist = &playlists.items[selected_index];
let selected_id = selected_playlist.id.clone();
let user_id = user.id.clone();
self.dispatch(IoEvent::UserUnfollowPlaylist(user_id, selected_id))
}
}
pub fn user_follow_show(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::SearchResultBlock => {
if let Some(shows) = &self.search_results.shows {
if let Some(selected_index) = self.search_results.selected_shows_index {
if let Some(show_id) = shows.items.get(selected_index).map(|item| item.id.clone()) {
self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));
}
}
}
}
ActiveBlock::EpisodeTable => match self.episode_table_context {
EpisodeTableContext::Full => {
if let Some(selected_episode) = self.selected_show_full.clone() {
let show_id = selected_episode.show.id;
self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));
}
}
EpisodeTableContext::Simplified => {
if let Some(selected_episode) = self.selected_show_simplified.clone() {
let show_id = selected_episode.show.id;
self.dispatch(IoEvent::CurrentUserSavedShowAdd(show_id));
}
}
},
_ => (),
}
}
pub fn user_unfollow_show(&mut self, block: ActiveBlock) {
match block {
ActiveBlock::Podcasts => {
if let Some(shows) = self.library.saved_shows.get_results(None) {
if let Some(selected_show) = shows.items.get(self.shows_list_index) {
let show_id = selected_show.show.id.clone();
self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));
}
}
}
ActiveBlock::SearchResultBlock => {
if let Some(shows) = &self.search_results.shows {
if let Some(selected_index) = self.search_results.selected_shows_index {
let show_id = shows.items[selected_index].id.to_owned();
self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));
}
}
}
ActiveBlock::EpisodeTable => match self.episode_table_context {
EpisodeTableContext::Full => {
if let Some(selected_episode) = self.selected_show_full.clone() {
let show_id = selected_episode.show.id;
self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));
}
}
EpisodeTableContext::Simplified => {
if let Some(selected_episode) = self.selected_show_simplified.clone() {
let show_id = selected_episode.show.id;
self.dispatch(IoEvent::CurrentUserSavedShowDelete(show_id));
}
}
},
_ => (),
}
}
pub fn get_made_for_you(&mut self) {
// TODO: replace searches when relevant endpoint is added
const DISCOVER_WEEKLY: &str = "Discover Weekly";
const RELEASE_RADAR: &str = "Release Radar";
const ON_REPEAT: &str = "On Repeat";
const REPEAT_REWIND: &str = "Repeat Rewind";
const DAILY_DRIVE: &str = "Daily Drive";
if self.library.made_for_you_playlists.pages.is_empty() {
// We shouldn't be fetching all the results immediately - only load the data when the
// user selects the playlist
self.made_for_you_search_and_add(DISCOVER_WEEKLY);
self.made_for_you_search_and_add(RELEASE_RADAR);
self.made_for_you_search_and_add(ON_REPEAT);
self.made_for_you_search_and_add(REPEAT_REWIND);
self.made_for_you_search_and_add(DAILY_DRIVE);
}
}
fn made_for_you_search_and_add(&mut self, search_string: &str) {
let user_country = self.get_user_country();
self.dispatch(IoEvent::MadeForYouSearchAndAdd(
search_string.to_string(),
user_country,
));
}
pub fn get_audio_analysis(&mut self) {
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &self.current_playback_context
{
match item {
PlayingItem::Track(track) => {
if self.get_current_route().id != RouteId::Analysis {
let uri = track.uri.clone();
self.dispatch(IoEvent::GetAudioAnalysis(uri));
self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis);
}
}
PlayingItem::Episode(_episode) => {
// No audio analysis available for podcast uris, so just default to the empty analysis
// view to avoid a 400 error code
self.push_navigation_stack(RouteId::Analysis, ActiveBlock::Analysis);
}
}
}
}
pub fn repeat(&mut self) {
if let Some(context) = &self.current_playback_context.clone() {
self.dispatch(IoEvent::Repeat(context.repeat_state));
}
}
pub fn get_artist(&mut self, artist_id: String, input_artist_name: String) {
let user_country = self.get_user_country();
self.dispatch(IoEvent::GetArtist(
artist_id,
input_artist_name,
user_country,
));
}
pub fn get_user_country(&self) -> Option<Country> {
self
.user
.to_owned()
.and_then(|user| Country::from_str(&user.country.unwrap_or_else(|| "".to_string())).ok())
}
pub fn calculate_help_menu_offset(&mut self) {
let old_offset = self.help_menu_offset;
if self.help_menu_max_lines < self.help_docs_size {
self.help_menu_offset = self.help_menu_page * self.help_menu_max_lines;
}
if self.help_menu_offset > self.help_docs_size {
self.help_menu_offset = old_offset;
self.help_menu_page -= 1;
}
}
}
================================================
FILE: src/banner.rs
================================================
pub const BANNER: &str = "
_________ ____ / /_(_) __/_ __ / /___ __(_)
/ ___/ __ \\/ __ \\/ __/ / /_/ / / /_____/ __/ / / / /
(__ ) /_/ / /_/ / /_/ / __/ /_/ /_____/ /_/ /_/ / /
/____/ .___/\\____/\\__/_/_/ \\__, / \\__/\\__,_/_/
/_/ /____/
";
================================================
FILE: src/cli/clap.rs
================================================
use clap::{App, Arg, ArgGroup, SubCommand};
fn device_arg() -> Arg<'static, 'static> {
Arg::with_name("device")
.short("d")
.long("device")
.takes_value(true)
.value_name("DEVICE")
.help("Specifies the spotify device to use")
}
fn format_arg() -> Arg<'static, 'static> {
Arg::with_name("format")
.short("f")
.long("format")
.takes_value(true)
.value_name("FORMAT")
.help("Specifies the output format")
.long_help(
"There are multiple format specifiers you can use: %a: artist, %b: album, %p: playlist, \
%t: track, %h: show, %f: flags (shuffle, repeat, like), %s: playback status, %v: volume, %d: current device. \
Example: spt pb -s -f 'playing on %d at %v%'",
)
}
pub fn playback_subcommand() -> App<'static, 'static> {
SubCommand::with_name("playback")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about("Interacts with the playback of a device")
.long_about(
"Use `playback` to interact with the playback of the current or any other device. \
You can specify another device with `--device`. If no options were provided, spt \
will default to just displaying the current playback. Actually, after every action \
spt will display the updated playback. The output format is configurable with the \
`--format` flag. Some options can be used together, other options have to be alone.
Here's a list:
* `--next` and `--previous` cannot be used with other options
* `--status`, `--toggle`, `--transfer`, `--volume`, `--like`, `--repeat` and `--shuffle` \
can be used together
* `--share-track` and `--share-album` cannot be used with other options",
)
.visible_alias("pb")
.arg(device_arg())
.arg(
format_arg()
.default_value("%f %s %t - %a")
.default_value_ifs(&[
("seek", None, "%f %s %t - %a %r"),
("volume", None, "%v% %f %s %t - %a"),
("transfer", None, "%f %s %t - %a on %d"),
]),
)
.arg(
Arg::with_name("toggle")
.short("t")
.long("toggle")
.help("Pauses/resumes the playback of a device"),
)
.arg(
Arg::with_name("status")
.short("s")
.long("status")
.help("Prints out the current status of a device (default)"),
)
.arg(
Arg::with_name("share-track")
.long("share-track")
.help("Returns the url to the current track"),
)
.arg(
Arg::with_name("share-album")
.long("share-album")
.help("Returns the url to the album of the current track"),
)
.arg(
Arg::with_name("transfer")
.long("transfer")
.takes_value(true)
.value_name("DEVICE")
.help("Transfers the playback to new DEVICE"),
)
.arg(
Arg::with_name("like")
.long("like")
.help("Likes the current song if possible"),
)
.arg(
Arg::with_name("dislike")
.long("dislike")
.help("Dislikes the current song if possible"),
)
.arg(
Arg::with_name("shuffle")
.long("shuffle")
.help("Toggles shuffle mode"),
)
.arg(
Arg::with_name("repeat")
.long("repeat")
.help("Switches between repeat modes"),
)
.arg(
Arg::with_name("next")
.short("n")
.long("next")
.multiple(true)
.help("Jumps to the next song")
.long_help(
"This jumps to the next song if specied once. If you want to jump, let's say 3 songs \
forward, you can use `--next` 3 times: `spt pb -nnn`.",
),
)
.arg(
Arg::with_name("previous")
.short("p")
.long("previous")
.multiple(true)
.help("Jumps to the previous song")
.long_help(
"This jumps to the beginning of the current song if specied once. You probably want to \
jump to the previous song though, so you can use the previous flag twice: `spt pb -pp`. To jump \
two songs back, you can use `spt pb -ppp` and so on.",
),
)
.arg(
Arg::with_name("seek")
.long("seek")
.takes_value(true)
.value_name("±SECONDS")
.allow_hyphen_values(true)
.help("Jumps SECONDS forwards (+) or backwards (-)")
.long_help(
"For example: `spt pb --seek +10` jumps ten second forwards, `spt pb --seek -10` ten \
seconds backwards and `spt pb --seek 10` to the tenth second of the track.",
),
)
.arg(
Arg::with_name("volume")
.short("v")
.long("volume")
.takes_value(true)
.value_name("VOLUME")
.help("Sets the volume of a device to VOLUME (1 - 100)"),
)
.group(
ArgGroup::with_name("jumps")
.args(&["next", "previous"])
.multiple(false)
.conflicts_with_all(&["single", "flags", "actions"]),
)
.group(
ArgGroup::with_name("likes")
.args(&["like", "dislike"])
.multiple(false),
)
.group(
ArgGroup::with_name("flags")
.args(&["like", "dislike", "shuffle", "repeat"])
.multiple(true)
.conflicts_with_all(&["single", "jumps"]),
)
.group(
ArgGroup::with_name("actions")
.args(&["toggle", "status", "transfer", "volume"])
.multiple(true)
.conflicts_with_all(&["single", "jumps"]),
)
.group(
ArgGroup::with_name("single")
.args(&["share-track", "share-album"])
.multiple(false)
.conflicts_with_all(&["actions", "flags", "jumps"]),
)
}
pub fn play_subcommand() -> App<'static, 'static> {
SubCommand::with_name("play")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about("Plays a uri or another spotify item by name")
.long_about(
"If you specify a uri, the type can be inferred. If you want to play something by \
name, you have to specify the type: `--track`, `--album`, `--artist`, `--playlist` \
or `--show`. The first item which was found will be played without confirmation. \
To add a track to the queue, use `--queue`. To play a random song from a playlist, \
use `--random`. Again, with `--format` you can specify how the output will look. \
The same function as found in `playback` will be called.",
)
.visible_alias("p")
.arg(device_arg())
.arg(format_arg().default_value("%f %s %t - %a"))
.arg(
Arg::with_name("uri")
.short("u")
.long("uri")
.takes_value(true)
.value_name("URI")
.help("Plays the URI"),
)
.arg(
Arg::with_name("name")
.short("n")
.long("name")
.takes_value(true)
.value_name("NAME")
.requires("contexts")
.help("Plays the first match with NAME from the specified category"),
)
.arg(
Arg::with_name("queue")
.short("q")
.long("queue")
// Only works with tracks
.conflicts_with_all(&["album", "artist", "playlist", "show"])
.help("Adds track to queue instead of playing it directly"),
)
.arg(
Arg::with_name("random")
.short("r")
.long("random")
// Only works with playlists
.conflicts_with_all(&["track", "album", "artist", "show"])
.help("Plays a random track (only works with playlists)"),
)
.arg(
Arg::with_name("album")
.short("b")
.long("album")
.help("Looks for an album"),
)
.arg(
Arg::with_name("artist")
.short("a")
.long("artist")
.help("Looks for an artist"),
)
.arg(
Arg::with_name("track")
.short("t")
.long("track")
.help("Looks for a track"),
)
.arg(
Arg::with_name("show")
.short("w")
.long("show")
.help("Looks for a show"),
)
.arg(
Arg::with_name("playlist")
.short("p")
.long("playlist")
.help("Looks for a playlist"),
)
.group(
ArgGroup::with_name("contexts")
.args(&["track", "artist", "playlist", "album", "show"])
.multiple(false),
)
.group(
ArgGroup::with_name("actions")
.args(&["uri", "name"])
.multiple(false)
.required(true),
)
}
pub fn list_subcommand() -> App<'static, 'static> {
SubCommand::with_name("list")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about("Lists devices, liked songs and playlists")
.long_about(
"This will list devices, liked songs or playlists. With the `--limit` flag you are \
able to specify the amount of results (between 1 and 50). Here, the `--format` is \
even more awesome, get your output exactly the way you want. The format option will \
be applied to every item found.",
)
.visible_alias("l")
.arg(format_arg().default_value_ifs(&[
("devices", None, "%v% %d"),
("liked", None, "%t - %a (%u)"),
("playlists", None, "%p (%u)"),
]))
.arg(
Arg::with_name("devices")
.short("d")
.long("devices")
.help("Lists devices"),
)
.arg(
Arg::with_name("playlists")
.short("p")
.long("playlists")
.help("Lists playlists"),
)
.arg(
Arg::with_name("liked")
.long("liked")
.help("Lists liked songs"),
)
.arg(
Arg::with_name("limit")
.long("limit")
.takes_value(true)
.help("Specifies the maximum number of results (1 - 50)"),
)
.group(
ArgGroup::with_name("listable")
.args(&["devices", "playlists", "liked"])
.required(true)
.multiple(false),
)
}
pub fn search_subcommand() -> App<'static, 'static> {
SubCommand::with_name("search")
.version(env!("CARGO_PKG_VERSION"))
.author(env!("CARGO_PKG_AUTHORS"))
.about("Searches for tracks, albums and more")
.long_about(
"This will search for something on spotify and displays you the items. The output \
format can be changed with the `--format` flag and the limit can be changed with \
the `--limit` flag (between 1 and 50). The type can't be inferred, so you have to \
specify it.",
)
.visible_alias("s")
.arg(format_arg().default_value_ifs(&[
("tracks", None, "%t - %a (%u)"),
("playlists", None, "%p (%u)"),
("artists", None, "%a (%u)"),
("albums", None, "%b - %a (%u)"),
("shows", None, "%h - %a (%u)"),
]))
.arg(
Arg::with_name("search")
.required(true)
.takes_value(true)
.value_name("SEARCH")
.help("Specifies the search query"),
)
.arg(
Arg::with_name("albums")
.short("b")
.long("albums")
.help("Looks for albums"),
)
.arg(
Arg::with_name("artists")
.short("a")
.long("artists")
.help("Looks for artists"),
)
.arg(
Arg::with_name("playlists")
.short("p")
.long("playlists")
.help("Looks for playlists"),
)
.arg(
Arg::with_name("tracks")
.short("t")
.long("tracks")
.help("Looks for tracks"),
)
.arg(
Arg::with_name("shows")
.short("w")
.long("shows")
.help("Looks for shows"),
)
.arg(
Arg::with_name("limit")
.long("limit")
.takes_value(true)
.help("Specifies the maximum number of results (1 - 50)"),
)
.group(
ArgGroup::with_name("searchable")
.args(&["playlists", "tracks", "albums", "artists", "shows"])
.required(true)
.multiple(false),
)
}
================================================
FILE: src/cli/cli_app.rs
================================================
use crate::network::{IoEvent, Network};
use crate::user_config::UserConfig;
use super::util::{Flag, Format, FormatType, JumpDirection, Type};
use anyhow::{anyhow, Result};
use rand::{thread_rng, Rng};
use rspotify::model::{context::CurrentlyPlaybackContext, PlayingItem};
pub struct CliApp<'a> {
pub net: Network<'a>,
pub config: UserConfig,
}
// Non-concurrent functions
// I feel that async in a cli is not working
// I just .await all processes and directly interact
// by calling network.handle_network_event
impl<'a> CliApp<'a> {
pub fn new(net: Network<'a>, config: UserConfig) -> Self {
Self { net, config }
}
async fn is_a_saved_track(&mut self, id: &str) -> bool {
// Update the liked_song_ids_set
self
.net
.handle_network_event(IoEvent::CurrentUserSavedTracksContains(
vec![id.to_string()],
))
.await;
self.net.app.lock().await.liked_song_ids_set.contains(id)
}
pub fn format_output(&self, mut format: String, values: Vec<Format>) -> String {
for val in values {
format = format.replace(val.get_placeholder(), &val.inner(self.config.clone()));
}
// Replace unsupported flags with 'None'
for p in &["%a", "%b", "%t", "%p", "%h", "%u", "%d", "%v", "%f", "%s"] {
format = format.replace(p, "None");
}
format.trim().to_string()
}
// spt playback -t
pub async fn toggle_playback(&mut self) {
let context = self.net.app.lock().await.current_playback_context.clone();
if let Some(c) = context {
if c.is_playing {
self.net.handle_network_event(IoEvent::PausePlayback).await;
return;
}
}
self
.net
.handle_network_event(IoEvent::StartPlayback(None, None, None))
.await;
}
// spt pb --share-track (share the current playing song)
// Basically copy-pasted the 'copy_song_url' function
pub async fn share_track_or_episode(&mut self) -> Result<String> {
let app = self.net.app.lock().await;
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &app.current_playback_context
{
match item {
PlayingItem::Track(track) => Ok(format!(
"https://open.spotify.com/track/{}",
track.id.to_owned().unwrap_or_default()
)),
PlayingItem::Episode(episode) => Ok(format!(
"https://open.spotify.com/episode/{}",
episode.id.to_owned()
)),
}
} else {
Err(anyhow!(
"failed to generate a shareable url for the current song"
))
}
}
// spt pb --share-album (share the current album)
// Basically copy-pasted the 'copy_album_url' function
pub async fn share_album_or_show(&mut self) -> Result<String> {
let app = self.net.app.lock().await;
if let Some(CurrentlyPlaybackContext {
item: Some(item), ..
}) = &app.current_playback_context
{
match item {
PlayingItem::Track(track) => Ok(format!(
"https://open.spotify.com/album/{}",
track.album.id.to_owned().unwrap_or_default()
)),
PlayingItem::Episode(episode) => Ok(format!(
"https://open.spotify.com/show/{}",
episode.show.id.to_owned()
)),
}
} else {
Err(anyhow!(
"failed to generate a shareable url for the current song"
))
}
}
// spt ... -d ... (specify device to control)
pub async fn set_device(&mut self, name: String) -> Result<()> {
// Change the device if specified by user
let mut app = self.net.app.lock().await;
let mut device_index = 0;
if let Some(dp) = &app.devices {
for (i, d) in dp.devices.iter().enumerate() {
if d.name == name {
device_index = i;
// Save the id of the device
self
.net
.client_config
.set_device_id(d.id.clone())
.map_err(|_e| anyhow!("failed to use device with name '{}'", d.name))?;
}
}
} else {
// Error out if no device is available
return Err(anyhow!("no device available"));
}
app.selected_device_index = Some(device_index);
Ok(())
}
// spt query ... --limit LIMIT (set max search limit)
pub async fn update_query_limits(&mut self, max: String) -> Result<()> {
let num = max
.parse::<u32>()
.map_err(|_e| anyhow!("limit must be between 1 and 50"))?;
// 50 seems to be the maximum limit
if num > 50 || num == 0 {
return Err(anyhow!("limit must be between 1 and 50"));
};
self
.net
.handle_network_event(IoEvent::UpdateSearchLimits(num, num))
.await;
Ok(())
}
pub async fn volume(&mut self, vol: String) -> Result<()> {
let num = vol
.parse::<u32>()
.map_err(|_e| anyhow!("volume must be between 0 and 100"))?;
// Check if it's in range
if num > 100 {
return Err(anyhow!("volume must be between 0 and 100"));
};
self
.net
.handle_network_event(IoEvent::ChangeVolume(num as u8))
.await;
Ok(())
}
// spt playback --next / --previous
pub async fn jump(&mut self, d: &JumpDirection) {
match d {
JumpDirection::Next => self.net.handle_network_event(IoEvent::NextTrack).await,
JumpDirection::Previous => self.net.handle_network_event(IoEvent::PreviousTrack).await,
}
}
// spt query -l ...
pub async fn list(&mut self, item: Type, format: &str) -> String {
match item {
Type::Device => {
if let Some(devices) = &self.net.app.lock().await.devices {
devices
.devices
.iter()
.map(|d| {
self.format_output(
format.to_string(),
vec![
Format::Device(d.name.clone()),
Format::Volume(d.volume_percent),
],
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
"No devices available".to_string()
}
}
Type::Playlist => {
self.net.handle_network_event(IoEvent::GetPlaylists).await;
if let Some(playlists) = &self.net.app.lock().await.playlists {
playlists
.items
.iter()
.map(|p| {
self.format_output(
format.to_string(),
Format::from_type(FormatType::Playlist(Box::new(p.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
"No playlists found".to_string()
}
}
Type::Liked => {
self
.net
.handle_network_event(IoEvent::GetCurrentSavedTracks(None))
.await;
let liked_songs = self
.net
.app
.lock()
.await
.track_table
.tracks
.iter()
.map(|t| {
self.format_output(
format.to_string(),
Format::from_type(FormatType::Track(Box::new(t.clone()))),
)
})
.collect::<Vec<String>>();
// Check if there are any liked songs
if liked_songs.is_empty() {
"No liked songs found".to_string()
} else {
liked_songs.join("\n")
}
}
// Enforced by clap
_ => unreachable!(),
}
}
// spt playback --transfer DEVICE
pub async fn transfer_playback(&mut self, device: &str) -> Result<()> {
// Get the device id by name
let mut id = String::new();
if let Some(devices) = &self.net.app.lock().await.devices {
for d in &devices.devices {
if d.name == device {
id.push_str(d.id.as_str());
break;
}
}
};
if id.is_empty() {
Err(anyhow!("no device with name '{}'", device))
} else {
self
.net
.handle_network_event(IoEvent::TransferPlaybackToDevice(id.to_string()))
.await;
Ok(())
}
}
pub async fn seek(&mut self, seconds_str: String) -> Result<()> {
let seconds = match seconds_str.parse::<i32>() {
Ok(s) => s.abs() as u32,
Err(_) => return Err(anyhow!("failed to convert seconds to i32")),
};
let (current_pos, duration) = {
self
.net
.handle_network_event(IoEvent::GetCurrentPlayback)
.await;
let app = self.net.app.lock().await;
if let Some(CurrentlyPlaybackContext {
progress_ms: Some(ms),
item: Some(item),
..
}) = &app.current_playback_context
{
let duration = match item {
PlayingItem::Track(track) => track.duration_ms,
PlayingItem::Episode(episode) => episode.duration_ms,
};
(*ms as u32, duration)
} else {
return Err(anyhow!("no context available"));
}
};
// Convert secs to ms
let ms = seconds * 1000;
// Calculate new positon
let position_to_seek = if seconds_str.starts_with('+') {
current_pos + ms
} else if seconds_str.starts_with('-') {
// Jump to the beginning if the position_to_seek would be
// negative, must be checked before the calculation to avoid
// an 'underflow'
if ms > current_pos {
0u32
} else {
current_pos - ms
}
} else {
// Absolute value of the track
seconds * 1000
};
// Check if position_to_seek is greater than duration (next track)
if position_to_seek > duration {
self.jump(&JumpDirection::Next).await;
} else {
// This seeks to a position in the current song
self
.net
.handle_network_event(IoEvent::Seek(position_to_seek))
.await;
}
Ok(())
}
// spt playback --like / --dislike / --shuffle / --repeat
pub async fn mark(&mut self, flag: Flag) -> Result<()> {
let c = {
let app = self.net.app.lock().await;
app
.current_playback_context
.clone()
.ok_or_else(|| anyhow!("no context available"))?
};
match flag {
Flag::Like(s) => {
// Get the id of the current song
let id = match c.item {
Some(i) => match i {
PlayingItem::Track(t) => t.id.ok_or_else(|| anyhow!("item has no id")),
PlayingItem::Episode(_) => Err(anyhow!("saving episodes not yet implemented")),
},
None => Err(anyhow!("no item playing")),
}?;
// Want to like but is already liked -> do nothing
// Want to like and is not liked yet -> like
if s && !self.is_a_saved_track(&id).await {
self
.net
.handle_network_event(IoEvent::ToggleSaveTrack(id))
.await;
// Want to dislike but is already disliked -> do nothing
// Want to dislike and is liked currently -> remove like
} else if !s && self.is_a_saved_track(&id).await {
self
.net
.handle_network_event(IoEvent::ToggleSaveTrack(id))
.await;
}
}
Flag::Shuffle => {
self
.net
.handle_network_event(IoEvent::Shuffle(c.shuffle_state))
.await
}
Flag::Repeat => {
self
.net
.handle_network_event(IoEvent::Repeat(c.repeat_state))
.await;
}
}
Ok(())
}
// spt playback -s
pub async fn get_status(&mut self, format: String) -> Result<String> {
// Update info on current playback
self
.net
.handle_network_event(IoEvent::GetCurrentPlayback)
.await;
self
.net
.handle_network_event(IoEvent::GetCurrentSavedTracks(None))
.await;
let context = self
.net
.app
.lock()
.await
.current_playback_context
.clone()
.ok_or_else(|| anyhow!("no context available"))?;
let playing_item = context.item.ok_or_else(|| anyhow!("no track playing"))?;
let mut hs = match playing_item {
PlayingItem::Track(track) => {
let id = track.id.clone().unwrap_or_default();
let mut hs = Format::from_type(FormatType::Track(Box::new(track.clone())));
if let Some(ms) = context.progress_ms {
hs.push(Format::Position((ms, track.duration_ms)))
}
hs.push(Format::Flags((
context.repeat_state,
context.shuffle_state,
self.is_a_saved_track(&id).await,
)));
hs
}
PlayingItem::Episode(episode) => {
let mut hs = Format::from_type(FormatType::Episode(Box::new(episode.clone())));
if let Some(ms) = context.progress_ms {
hs.push(Format::Position((ms, episode.duration_ms)))
}
hs.push(Format::Flags((
context.repeat_state,
context.shuffle_state,
false,
)));
hs
}
};
hs.push(Format::Device(context.device.name));
hs.push(Format::Volume(context.device.volume_percent));
hs.push(Format::Playing(context.is_playing));
Ok(self.format_output(format, hs))
}
// spt play -u URI
pub async fn play_uri(&mut self, uri: String, queue: bool, random: bool) {
let offset = if random {
// Only works with playlists for now
if uri.contains("spotify:playlist:") {
let id = uri.split(':').last().unwrap();
match self.net.spotify.playlist(id, None, None).await {
Ok(p) => {
let num = p.tracks.total;
Some(thread_rng().gen_range(0..num) as usize)
}
Err(e) => {
self
.net
.app
.lock()
.await
.handle_error(anyhow!(e.to_string()));
return;
}
}
} else {
None
}
} else {
None
};
if uri.contains("spotify:track:") {
if queue {
self
.net
.handle_network_event(IoEvent::AddItemToQueue(uri))
.await;
} else {
self
.net
.handle_network_event(IoEvent::StartPlayback(
None,
Some(vec![uri.clone()]),
Some(0),
))
.await;
}
} else {
self
.net
.handle_network_event(IoEvent::StartPlayback(Some(uri.clone()), None, offset))
.await;
}
}
// spt play -n NAME ...
pub async fn play(&mut self, name: String, item: Type, queue: bool, random: bool) -> Result<()> {
self
.net
.handle_network_event(IoEvent::GetSearchResults(name.clone(), None))
.await;
// Get the uri of the first found
// item + the offset or return an error message
let uri = {
let results = &self.net.app.lock().await.search_results;
match item {
Type::Track => {
if let Some(r) = &results.tracks {
r.items[0].uri.clone()
} else {
return Err(anyhow!("no tracks with name '{}'", name));
}
}
Type::Album => {
if let Some(r) = &results.albums {
let album = &r.items[0];
if let Some(uri) = &album.uri {
uri.clone()
} else {
return Err(anyhow!("album {} has no uri", album.name));
}
} else {
return Err(anyhow!("no albums with name '{}'", name));
}
}
Type::Artist => {
if let Some(r) = &results.artists {
r.items[0].uri.clone()
} else {
return Err(anyhow!("no artists with name '{}'", name));
}
}
Type::Show => {
if let Some(r) = &results.shows {
r.items[0].uri.clone()
} else {
return Err(anyhow!("no shows with name '{}'", name));
}
}
Type::Playlist => {
if let Some(r) = &results.playlists {
let p = &r.items[0];
// For a random song, create a random offset
p.uri.clone()
} else {
return Err(anyhow!("no playlists with name '{}'", name));
}
}
_ => unreachable!(),
}
};
// Play or queue the uri
self.play_uri(uri, queue, random).await;
Ok(())
}
// spt query -s SEARCH ...
pub async fn query(&mut self, search: String, format: String, item: Type) -> String {
self
.net
.handle_network_event(IoEvent::GetSearchResults(search.clone(), None))
.await;
let app = self.net.app.lock().await;
match item {
Type::Playlist => {
if let Some(results) = &app.search_results.playlists {
results
.items
.iter()
.map(|r| {
self.format_output(
format.clone(),
Format::from_type(FormatType::Playlist(Box::new(r.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
format!("no playlists with name '{}'", search)
}
}
Type::Track => {
if let Some(results) = &app.search_results.tracks {
results
.items
.iter()
.map(|r| {
self.format_output(
format.clone(),
Format::from_type(FormatType::Track(Box::new(r.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
format!("no tracks with name '{}'", search)
}
}
Type::Artist => {
if let Some(results) = &app.search_results.artists {
results
.items
.iter()
.map(|r| {
self.format_output(
format.clone(),
Format::from_type(FormatType::Artist(Box::new(r.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
format!("no artists with name '{}'", search)
}
}
Type::Show => {
if let Some(results) = &app.search_results.shows {
results
.items
.iter()
.map(|r| {
self.format_output(
format.clone(),
Format::from_type(FormatType::Show(Box::new(r.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
format!("no shows with name '{}'", search)
}
}
Type::Album => {
if let Some(results) = &app.search_results.albums {
results
.items
.iter()
.map(|r| {
self.format_output(
format.clone(),
Format::from_type(FormatType::Album(Box::new(r.clone()))),
)
})
.collect::<Vec<String>>()
.join("\n")
} else {
format!("no albums with name '{}'", search)
}
}
// Enforced by clap
_ => unreachable!(),
}
}
}
================================================
FILE: src/cli/handle.rs
================================================
use crate::network::{IoEvent, Network};
use crate::user_config::UserConfig;
use super::{
util::{Flag, JumpDirection, Type},
CliApp,
};
use anyhow::{anyhow, Result};
use clap::ArgMatches;
// Handle the different subcommands
pub async fn handle_matches(
matches: &ArgMatches<'_>,
cmd: String,
net: Network<'_>,
config: UserConfig,
) -> Result<String> {
let mut cli = CliApp::new(net, config);
cli.net.handle_network_event(IoEvent::GetDevices).await;
cli
.net
.handle_network_event(IoEvent::GetCurrentPlayback)
.await;
let devices_list = match &cli.net.app.lock().await.devices {
Some(p) => p
.devices
.iter()
.map(|d| d.id.clone())
.collect::<Vec<String>>(),
None => Vec::new(),
};
// If the device_id is not specified, select the first available device
let device_id = cli.net.client_config.device_id.clone();
if device_id.is_none() || !devices_list.contains(&device_id.unwrap()) {
// Select the first device available
if let Some(d) = devices_list.get(0) {
cli.net.client_config.set_device_id(d.clone())?;
}
}
if let Some(d) = matches.value_of("device") {
cli.set_device(d.to_string()).await?;
}
// Evalute the subcommand
let output = match cmd.as_str() {
"playback" => {
let format = matches.value_of("format").unwrap();
// Commands that are 'single'
if matches.is_present("share-track") {
return cli.share_track_or_episode().await;
} else if matches.is_present("share-album") {
return cli.share_album_or_show().await;
}
// Run the action, and print out the status
// No 'else if's because multiple different commands are possible
if matches.is_present("toggle") {
cli.toggle_playback().await;
}
if let Some(d) = matches.value_of("transfer") {
cli.transfer_playback(d).await?;
}
// Multiple flags are possible
if matches.is_present("flags") {
let flags = Flag::from_matches(matches);
for f in flags {
cli.mark(f).await?;
}
}
if matches.is_present("jumps") {
let (direction, amount) = JumpDirection::from_matches(matches);
for _ in 0..amount {
cli.jump(&direction).await;
}
}
if let Some(vol) = matches.value_of("volume") {
cli.volume(vol.to_string()).await?;
}
if let Some(secs) = matches.value_of("seek") {
cli.seek(secs.to_string()).await?;
}
// Print out the status if no errors were found
cli.get_status(format.to_string()).await
}
"play" => {
let queue = matches.is_present("queue");
let random = matches.is_present("random");
let format = matches.value_of("format").unwrap();
if let Some(uri) = matches.value_of("uri") {
cli.play_uri(uri.to_string(), queue, random).await;
} else if let Some(name) = matches.value_of("name") {
let category = Type::play_from_matches(matches);
cli.play(name.to_string(), category, queue, random).await?;
}
cli.get_status(format.to_string()).await
}
"list" => {
let format = matches.value_of("format").unwrap().to_string();
// Update the limits for the list and search functions
// I think the small and big search limits are very confusing
// so I just set them both to max, is this okay?
if let Some(max) = matches.value_of("limit") {
cli.update_query_limits(max.to_string()).await?;
}
let category = Type::list_from_matches(matches);
Ok(cli.list(category, &format).await)
}
"search" => {
let format = matches.value_of("format").unwrap().to_string();
// Update the limits for the list and search functions
// I think the small and big search limits are very confusing
// so I just set them both to max, is this okay?
if let Some(max) = matches.value_of("limit") {
cli.update_query_limits(max.to_string()).await?;
}
let category = Type::search_from_matches(matches);
Ok(
cli
.query(
matches.value_of("search").unwrap().to_string(),
format,
category,
)
.await,
)
}
// Clap enforces that one of the things above is specified
_ => unreachable!(),
};
// Check if there was an error
let api_error = cli.net.app.lock().await.api_error.clone();
if api_error.is_empty() {
output
} else {
Err(anyhow!("{}", api_error))
}
}
================================================
FILE: src/cli/mod.rs
================================================
mod clap;
mod cli_app;
mod handle;
mod util;
pub use self::clap::{list_subcommand, play_subcommand, playback_subcommand, search_subcommand};
use cli_app::CliApp;
pub use handle::handle_matches;
================================================
FILE: src/cli/util.rs
================================================
use clap::ArgMatches;
use rspotify::{
model::{
album::SimplifiedAlbum, artist::FullArtist, artist::SimplifiedArtist,
playlist::SimplifiedPlaylist, show::FullEpisode, show::SimplifiedShow, track::FullTrack,
},
senum::RepeatState,
};
use crate::user_config::UserConfig;
// Possible types to list or search
#[derive(Debug)]
pub enum Type {
Playlist,
Track,
Artist,
Album,
Show,
Device,
Liked,
}
impl Type {
pub fn play_from_matches(m: &ArgMatches<'_>) -> Self {
if m.is_present("playlist") {
Self::Playlist
} else if m.is_present("track") {
Self::Track
} else if m.is_present("artist") {
Self::Artist
} else if m.is_present("album") {
Self::Album
} else if m.is_present("show") {
Self::Show
}
// Enforced by clap
else {
unreachable!()
}
}
pub fn search_from_matches(m: &ArgMatches<'_>) -> Self {
if m.is_present("playlists") {
Self::Playlist
} else if m.is_present("tracks") {
Self::Track
} else if m.is_present("artists") {
Self::Artist
} else if m.is_present("albums") {
Self::Album
} else if m.is_present("shows") {
Self::Show
}
// Enforced by clap
else {
unreachable!()
}
}
pub fn list_from_matches(m: &ArgMatches<'_>) -> Self {
if m.is_present("playlists") {
Self::Playlist
} else if m.is_present("devices") {
Self::Device
} else if m.is_present("liked") {
Self::Liked
}
// Enforced by clap
else {
unreachable!()
}
}
}
//
// Possible flags to set
//
pub enum Flag {
// Does not get toggled
// * User chooses like -> Flag::Like(true)
// * User chooses dislike -> Flag::Like(false)
Like(bool),
Shuffle,
Repeat,
}
impl Flag {
pub fn from_matches(m: &ArgMatches<'_>) -> Vec<Self> {
// Multiple flags are possible
let mut flags = Vec::new();
// Only one of these two
if m.is_present("like") {
flags.push(Self::Like(true));
} else if m.is_present("dislike") {
flags.push(Self::Like(false));
}
if m.is_present("shuffle") {
flags.push(Self::Shuffle);
}
if m.is_present("repeat") {
flags.push(Self::Repeat);
}
flags
}
}
// Possible directions to jump to
pub enum JumpDirection {
Next,
Previous,
}
impl JumpDirection {
pub fn from_matches(m: &ArgMatches<'_>) -> (Self, u64) {
if m.is_present("next") {
(Self::Next, m.occurrences_of("next"))
} else if m.is_present("previous") {
(Self::Previous, m.occurrences_of("previous"))
// Enforced by clap
} else {
unreachable!()
}
}
}
// For fomatting (-f / --format flag)
// Types to create a Format enum from
// Boxing was proposed by cargo clippy
// to reduce the size of this enum
pub enum FormatType {
Album(Box<SimplifiedAlbum>),
Artist(Box<FullArtist>),
Playlist(Box<SimplifiedPlaylist>),
Track(Box<FullTrack>),
Episode(Box<FullEpisode>),
Show(Box<SimplifiedShow>),
}
// Types that can be formatted
#[derive(Clone)]
pub enum Format {
Album(String),
Artist(String),
Playlist(String),
Track(String),
Show(String),
Uri(String),
Device(String),
Volume(u32),
// Current position, duration
Position((u32, u32)),
// This is a bit long, should it be splitted up?
Flags((RepeatState, bool, bool)),
Playing(bool),
}
pub fn join_artists(a: Vec<SimplifiedArtist>) -> String {
a.iter()
.map(|l| l.name.clone())
.collect::<Vec<String>>()
.join(", ")
}
impl Format {
// Extract important information from types
pub fn from_type(t: FormatType) -> Vec<Self> {
match t {
FormatType::Album(a) => {
let joined_artists = join_artists(a.artists.clone());
let mut vec = vec![Self::Album(a.name), Self::Artist(joined_artists)];
if let Some(uri) = a.uri {
vec.push(Self::Uri(uri));
}
vec
}
FormatType::Artist(a) => vec![Self::Artist(a.name), Self::Uri(a.uri)],
FormatType::Playlist(p) => vec![Self::Playlist(p.name), Self::Uri(p.uri)],
FormatType::Track(t) => {
let joined_artists = join_artists(t.artists.clone());
vec![
Self::Album(t.album.name),
Self::Artist(joined_artists),
Self::Track(t.name),
Self::Uri(t.uri),
]
}
FormatType::Show(r) => vec![
Self::Artist(r.publisher),
Self::Show(r.name),
Self::Uri(r.uri),
],
FormatType::Episode(e) => vec![
Self::Show(e.show.name),
Self::Artist(e.show.publisher),
Self::Track(e.name),
Self::Uri(e.uri),
],
}
}
// Is there a better way?
pub fn inner(&self, conf: UserConfig) -> String {
match self {
Self::Album(s) => s.clone(),
Self::Artist(s) => s.clone(),
Self::Playlist(s) => s.clone(),
Self::Track(s) => s.clone(),
Self::Show(s) => s.clone(),
Self::Uri(s) => s.clone(),
Self::Device(s) => s.clone(),
// Because this match statements
// needs to return a &String, I have to do it this way
Self::Volume(s) => s.to_string(),
Self::Position((curr, duration)) => {
crate::ui::util::display_track_progress(*curr as u128, *duration)
}
Self::Flags((r, s, l)) => {
let like = if *l {
conf.behavior.liked_icon
} else {
String::new()
};
let shuffle = if *s {
conf.behavior.shuffle_icon
} else {
String::new()
};
let repeat = match r {
RepeatState::Off => String::new(),
RepeatState::Track => conf.behavior.repeat_track_icon,
RepeatState::Context => conf.behavior.repeat_context_icon,
};
// Add them together (only those that aren't empty)
[shuffle, repeat, like]
.iter()
.filter(|a| !a.is_empty())
// Convert &String to String to join them
.map(|s| s.to_string())
.collect::<Vec<String>>()
.join(" ")
}
Self::Playing(s) => {
if *s {
conf.behavior.playing_icon
} else {
conf.behavior.paused_icon
}
}
}
}
pub fn get_placeholder(&self) -> &str {
match self {
Self::Album(_) => "%b",
Self::Artist(_) => "%a",
Self::Playlist(_) => "%p",
Self::Track(_) => "%t",
Self::Show(_) => "%h",
Self::Uri(_) => "%u",
Self::Device(_) => "%d",
Self::Volume(_) => "%v",
Self::Position(_) => "%r",
Self::Flags(_) => "%f",
Self::Playing(_) => "%s",
}
}
}
================================================
FILE: src/config.rs
================================================
use super::banner::BANNER;
use anyhow::{anyhow, Error, Result};
use serde::{Deserialize, Serialize};
use std::{
fs,
io::{stdin, Write},
path::{Path, PathBuf},
};
const DEFAULT_PORT: u16 = 8888;
const FILE_NAME: &str = "client.yml";
const CONFIG_DIR: &str = ".config";
const APP_CONFIG_DIR: &str = "spotify-tui";
const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json";
#[derive(Default, Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ClientConfig {
pub client_id: String,
pub client_secret: String,
pub device_id: Option<String>,
// FIXME: port should be defined in `user_config` not in here
pub port: Option<u16>,
}
pub struct ConfigPaths {
pub config_file_path: PathBuf,
pub token_cache_path: PathBuf,
}
impl ClientConfig {
pub fn new() -> ClientConfig {
ClientConfig {
client_id: "".to_string(),
client_secret: "".to_string(),
device_id: None,
port: None,
}
}
pub fn get_redirect_uri(&self) -> String {
format!("http://localhost:{}/callback", self.get_port())
}
pub fn get_port(&self) -> u16 {
self.port.unwrap_or(DEFAULT_PORT)
}
pub fn get_or_build_paths(&self) -> Result<ConfigPaths> {
match dirs::home_dir() {
Some(home) => {
let path = Path::new(&home);
let home_config_dir = path.join(CONFIG_DIR);
let app_config_dir = home_config_dir.join(APP_CONFIG_DIR);
if !home_config_dir.exists() {
fs::create_dir(&home_config_dir)?;
}
if !app_config_dir.exists() {
fs::create_dir(&app_config_dir)?;
}
let config_file_path = &app_config_dir.join(FILE_NAME);
let token_cache_path = &app_config_dir.join(TOKEN_CACHE_FILE);
let paths = ConfigPaths {
config_file_path: config_file_path.to_path_buf(),
token_cache_path: token_cache_path.to_path_buf(),
};
Ok(paths)
}
None => Err(anyhow!("No $HOME directory found for client config")),
}
}
pub fn set_device_id(&mut self, device_id: String) -> Result<()> {
let paths = self.get_or_build_paths()?;
let config_string = fs::read_to_string(&paths.config_file_path)?;
let mut config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;
self.device_id = Some(device_id.clone());
config_yml.device_id = Some(device_id);
let new_config = serde_yaml::to_string(&config_yml)?;
let mut config_file = fs::File::create(&paths.config_file_path)?;
write!(config_file, "{}", new_config)?;
Ok(())
}
pub fn load_config(&mut self) -> Result<()> {
let paths = self.get_or_build_paths()?;
if paths.config_file_path.exists() {
let config_string = fs::read_to_string(&paths.config_file_path)?;
let config_yml: ClientConfig = serde_yaml::from_str(&config_string)?;
self.client_id = config_yml.client_id;
self.client_secret = config_yml.client_secret;
self.device_id = config_yml.device_id;
self.port = config_yml.port;
Ok(())
} else {
println!("{}", BANNER);
println!(
"Config will be saved to {}",
paths.config_file_path.display()
);
println!("\nHow to get setup:\n");
let instructions = [
"Go to the Spotify dashboard - https://developer.spotify.com/dashboard/applications",
"Click `Create a Client ID` and create an app",
"Now click `Edit Settings`",
&format!(
"Add `http://localhost:{}/callback` to the Redirect URIs",
DEFAULT_PORT
),
"You are now ready to authenticate with Spotify!",
];
let mut number = 1;
for item in instructions.iter() {
println!(" {}. {}", number, item);
number += 1;
}
let client_id = ClientConfig::get_client_key_from_input("Client ID")?;
let client_secret = ClientConfig::get_client_key_from_input("Client Secret")?;
let mut port = String::new();
println!("\nEnter port of redirect uri (default {}): ", DEFAULT_PORT);
stdin().read_line(&mut port)?;
let port = port.trim().parse::<u16>().unwrap_or(DEFAULT_PORT);
let config_yml = ClientConfig {
client_id,
client_secret,
device_id: None,
port: Some(port),
};
let content_yml = serde_yaml::to_string(&config_yml)?;
let mut new_config = fs::File::create(&paths.config_file_path)?;
write!(new_config, "{}", content_yml)?;
self.client_id = config_yml.client_id;
self.client_secret = config_yml.client_secret;
self.device_id = config_yml.device_id;
self.port = config_yml.port;
Ok(())
}
}
fn get_client_key_from_input(type_label: &'static str) -> Result<String> {
let mut client_key = String::new();
const MAX_RETRIES: u8 = 5;
let mut num_retries = 0;
loop {
println!("\nEnter your {}: ", type_label);
stdin().read_line(&mut client_key)?;
client_key = client_key.trim().to_string();
match ClientConfig::validate_client_key(&client_key) {
Ok(_) => return Ok(client_key),
Err(error_string) => {
println!("{}", error_string);
client_key.clear();
num_retries += 1;
if num_retries == MAX_RETRIES {
return Err(Error::from(std::io::Error::new(
std::io::ErrorKind::Other,
format!("Maximum retries ({}) exceeded.", MAX_RETRIES),
)));
}
}
};
}
}
fn validate_client_key(key: &str) -> Result<()> {
const EXPECTED_LEN: usize = 32;
if key.len() != EXPECTED_LEN {
Err(Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid length: {} (must be {})", key.len(), EXPECTED_LEN,),
)))
} else if !key.chars().all(|c| c.is_digit(16)) {
Err(Error::from(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid character found (must be hex digits)",
)))
} else {
Ok(())
}
}
}
================================================
FILE: src/event/events.rs
================================================
use crate::event::Key;
use crossterm::event;
use std::{sync::mpsc, thread, time::Duration};
#[derive(Debug, Clone, Copy)]
/// Configuration for event handling.
pub struct EventConfig {
/// The key that is used to exit the application.
pub exit_key: Key,
/// The tick rate at which the application will sent an tick event.
pub tick_rate: Duration,
}
impl Default for EventConfig {
fn default() -> EventConfig {
EventConfig {
exit_key: Key::Ctrl('c'),
tick_rate: Duration::from_millis(250),
}
}
}
/// An occurred event.
pub enum Event<I> {
/// An input event occurred.
Input(I),
/// An tick event occurred.
Tick,
}
/// A small event handler that wrap crossterm input and tick event. Each event
/// type is handled in its own thread and returned to a common `Receiv
gitextract_9oul2w9i/
├── .all-contributorsrc
├── .github/
│ ├── FUNDING.yml
│ ├── dependabot.yml
│ └── workflows/
│ ├── cd.yml
│ └── ci.yml
├── .gitignore
├── CHANGELOG.md
├── Cargo.toml
├── LICENSE
├── README.md
├── how_to_release.md
├── rustfmt.toml
├── snap/
│ ├── gui/
│ │ └── spt.desktop
│ └── snapcraft.yaml
└── src/
├── app.rs
├── banner.rs
├── cli/
│ ├── clap.rs
│ ├── cli_app.rs
│ ├── handle.rs
│ ├── mod.rs
│ └── util.rs
├── config.rs
├── event/
│ ├── events.rs
│ ├── key.rs
│ └── mod.rs
├── handlers/
│ ├── album_list.rs
│ ├── album_tracks.rs
│ ├── analysis.rs
│ ├── artist.rs
│ ├── artist_albums.rs
│ ├── artists.rs
│ ├── basic_view.rs
│ ├── common_key_events.rs
│ ├── dialog.rs
│ ├── empty.rs
│ ├── episode_table.rs
│ ├── error_screen.rs
│ ├── help_menu.rs
│ ├── home.rs
│ ├── input.rs
│ ├── library.rs
│ ├── made_for_you.rs
│ ├── mod.rs
│ ├── playbar.rs
│ ├── playlist.rs
│ ├── podcasts.rs
│ ├── recently_played.rs
│ ├── search_results.rs
│ ├── select_device.rs
│ └── track_table.rs
├── main.rs
├── network.rs
├── redirect_uri.html
├── redirect_uri.rs
├── ui/
│ ├── audio_analysis.rs
│ ├── help.rs
│ ├── mod.rs
│ └── util.rs
├── user_config.rs
└── util.rs
SYMBOL INDEX (440 symbols across 43 files)
FILE: src/app.rs
constant LIBRARY_OPTIONS (line 32) | pub const LIBRARY_OPTIONS: [&str; 6] = [
constant DEFAULT_ROUTE (line 41) | const DEFAULT_ROUTE: Route = Route {
type ScrollableResultPages (line 48) | pub struct ScrollableResultPages<T> {
function new (line 54) | pub fn new() -> ScrollableResultPages<T> {
function get_results (line 61) | pub fn get_results(&self, at_index: Option<usize>) -> Option<&T> {
function get_mut_results (line 65) | pub fn get_mut_results(&mut self, at_index: Option<usize>) -> Option<&mu...
function add_pages (line 69) | pub fn add_pages(&mut self, new_pages: T) {
type SpotifyResultAndSelectedIndex (line 77) | pub struct SpotifyResultAndSelectedIndex<T> {
type Library (line 83) | pub struct Library {
type SearchResultBlock (line 94) | pub enum SearchResultBlock {
type ArtistBlock (line 104) | pub enum ArtistBlock {
type DialogContext (line 112) | pub enum DialogContext {
type ActiveBlock (line 118) | pub enum ActiveBlock {
type RouteId (line 144) | pub enum RouteId {
type Route (line 165) | pub struct Route {
type TrackTableContext (line 173) | pub enum TrackTableContext {
type AlbumTableContext (line 184) | pub enum AlbumTableContext {
type EpisodeTableContext (line 190) | pub enum EpisodeTableContext {
type RecommendationsContext (line 196) | pub enum RecommendationsContext {
type SearchResult (line 201) | pub struct SearchResult {
type TrackTable (line 217) | pub struct TrackTable {
type SelectedShow (line 224) | pub struct SelectedShow {
type SelectedFullShow (line 229) | pub struct SelectedFullShow {
type SelectedAlbum (line 234) | pub struct SelectedAlbum {
type SelectedFullAlbum (line 241) | pub struct SelectedFullAlbum {
type Artist (line 247) | pub struct Artist {
type App (line 259) | pub struct App {
method new (line 419) | pub fn new(
method dispatch (line 433) | pub fn dispatch(&mut self, action: IoEvent) {
method apply_seek (line 445) | fn apply_seek(&mut self, seek_ms: u32) {
method poll_current_playback (line 465) | fn poll_current_playback(&mut self) {
method update_on_tick (line 484) | pub fn update_on_tick(&mut self) {
method seek_forwards (line 517) | pub fn seek_forwards(&mut self) {
method seek_backwards (line 541) | pub fn seek_backwards(&mut self) {
method get_recommendations_for_seed (line 554) | pub fn get_recommendations_for_seed(
method get_recommendations_for_track_id (line 569) | pub fn get_recommendations_for_track_id(&mut self, id: String) {
method increase_volume (line 574) | pub fn increase_volume(&mut self) {
method decrease_volume (line 588) | pub fn decrease_volume(&mut self) {
method handle_error (line 602) | pub fn handle_error(&mut self, e: anyhow::Error) {
method toggle_playback (line 607) | pub fn toggle_playback(&mut self) {
method previous_track (line 619) | pub fn previous_track(&mut self) {
method push_navigation_stack (line 629) | pub fn push_navigation_stack(&mut self, next_route_id: RouteId, next_a...
method pop_navigation_stack (line 644) | pub fn pop_navigation_stack(&mut self) -> Option<Route> {
method get_current_route (line 652) | pub fn get_current_route(&self) -> &Route {
method get_current_route_mut (line 657) | fn get_current_route_mut(&mut self) -> &mut Route {
method set_current_route_state (line 661) | pub fn set_current_route_state(
method copy_song_url (line 675) | pub fn copy_song_url(&mut self) {
method copy_album_url (line 706) | pub fn copy_album_url(&mut self) {
method set_saved_tracks_to_table (line 737) | pub fn set_saved_tracks_to_table(&mut self, saved_track_page: &Page<Sa...
method set_saved_artists_to_table (line 748) | pub fn set_saved_artists_to_table(&mut self, saved_artists_page: &Curs...
method get_current_user_saved_artists_next (line 758) | pub fn get_current_user_saved_artists_next(&mut self) {
method get_current_user_saved_artists_previous (line 779) | pub fn get_current_user_saved_artists_previous(&mut self) {
method get_current_user_saved_tracks_next (line 789) | pub fn get_current_user_saved_tracks_next(&mut self) {
method get_current_user_saved_tracks_previous (line 810) | pub fn get_current_user_saved_tracks_previous(&mut self) {
method shuffle (line 820) | pub fn shuffle(&mut self) {
method get_current_user_saved_albums_next (line 826) | pub fn get_current_user_saved_albums_next(&mut self) {
method get_current_user_saved_albums_previous (line 843) | pub fn get_current_user_saved_albums_previous(&mut self) {
method current_user_saved_album_delete (line 849) | pub fn current_user_saved_album_delete(&mut self, block: ActiveBlock) {
method current_user_saved_album_add (line 882) | pub fn current_user_saved_album_add(&mut self, block: ActiveBlock) {
method get_current_user_saved_shows_next (line 907) | pub fn get_current_user_saved_shows_next(&mut self) {
method get_current_user_saved_shows_previous (line 924) | pub fn get_current_user_saved_shows_previous(&mut self) {
method get_episode_table_next (line 930) | pub fn get_episode_table_next(&mut self, show_id: String) {
method get_episode_table_previous (line 947) | pub fn get_episode_table_previous(&mut self) {
method user_unfollow_artists (line 953) | pub fn user_unfollow_artists(&mut self, block: ActiveBlock) {
method user_follow_artists (line 983) | pub fn user_follow_artists(&mut self, block: ActiveBlock) {
method user_follow_playlist (line 1005) | pub fn user_follow_playlist(&mut self) {
method user_unfollow_playlist (line 1024) | pub fn user_unfollow_playlist(&mut self) {
method user_unfollow_playlist_search_result (line 1035) | pub fn user_unfollow_playlist_search_result(&mut self) {
method user_follow_show (line 1048) | pub fn user_follow_show(&mut self, block: ActiveBlock) {
method user_unfollow_show (line 1077) | pub fn user_unfollow_show(&mut self, block: ActiveBlock) {
method get_made_for_you (line 1113) | pub fn get_made_for_you(&mut self) {
method made_for_you_search_and_add (line 1132) | fn made_for_you_search_and_add(&mut self, search_string: &str) {
method get_audio_analysis (line 1140) | pub fn get_audio_analysis(&mut self) {
method repeat (line 1162) | pub fn repeat(&mut self) {
method get_artist (line 1168) | pub fn get_artist(&mut self, artist_id: String, input_artist_name: Str...
method get_user_country (line 1177) | pub fn get_user_country(&self) -> Option<Country> {
method calculate_help_menu_offset (line 1184) | pub fn calculate_help_menu_offset(&mut self) {
method default (line 330) | fn default() -> Self {
FILE: src/banner.rs
constant BANNER (line 1) | pub const BANNER: &str = "
FILE: src/cli/clap.rs
function device_arg (line 3) | fn device_arg() -> Arg<'static, 'static> {
function format_arg (line 12) | fn format_arg() -> Arg<'static, 'static> {
function playback_subcommand (line 26) | pub fn playback_subcommand() -> App<'static, 'static> {
function play_subcommand (line 179) | pub fn play_subcommand() -> App<'static, 'static> {
function list_subcommand (line 271) | pub fn list_subcommand() -> App<'static, 'static> {
function search_subcommand (line 319) | pub fn search_subcommand() -> App<'static, 'static> {
FILE: src/cli/cli_app.rs
type CliApp (line 10) | pub struct CliApp<'a> {
function new (line 20) | pub fn new(net: Network<'a>, config: UserConfig) -> Self {
function is_a_saved_track (line 24) | async fn is_a_saved_track(&mut self, id: &str) -> bool {
function format_output (line 35) | pub fn format_output(&self, mut format: String, values: Vec<Format>) -> ...
function toggle_playback (line 47) | pub async fn toggle_playback(&mut self) {
function share_track_or_episode (line 63) | pub async fn share_track_or_episode(&mut self) -> Result<String> {
function share_album_or_show (line 88) | pub async fn share_album_or_show(&mut self) -> Result<String> {
function set_device (line 112) | pub async fn set_device(&mut self, name: String) -> Result<()> {
function update_query_limits (line 137) | pub async fn update_query_limits(&mut self, max: String) -> Result<()> {
function volume (line 154) | pub async fn volume(&mut self, vol: String) -> Result<()> {
function jump (line 172) | pub async fn jump(&mut self, d: &JumpDirection) {
function list (line 180) | pub async fn list(&mut self, item: Type, format: &str) -> String {
function transfer_playback (line 253) | pub async fn transfer_playback(&mut self, device: &str) -> Result<()> {
function seek (line 276) | pub async fn seek(&mut self, seconds_str: String) -> Result<()> {
function mark (line 339) | pub async fn mark(&mut self, flag: Flag) -> Result<()> {
function get_status (line 393) | pub async fn get_status(&mut self, format: String) -> Result<String> {
function play_uri (line 451) | pub async fn play_uri(&mut self, uri: String, queue: bool, random: bool) {
function play (line 503) | pub async fn play(&mut self, name: String, item: Type, queue: bool, rand...
function query (line 566) | pub async fn query(&mut self, search: String, format: String, item: Type...
FILE: src/cli/handle.rs
function handle_matches (line 13) | pub async fn handle_matches(
FILE: src/cli/util.rs
type Type (line 14) | pub enum Type {
method play_from_matches (line 25) | pub fn play_from_matches(m: &ArgMatches<'_>) -> Self {
method search_from_matches (line 43) | pub fn search_from_matches(m: &ArgMatches<'_>) -> Self {
method list_from_matches (line 61) | pub fn list_from_matches(m: &ArgMatches<'_>) -> Self {
type Flag (line 80) | pub enum Flag {
method from_matches (line 90) | pub fn from_matches(m: &ArgMatches<'_>) -> Vec<Self> {
type JumpDirection (line 112) | pub enum JumpDirection {
method from_matches (line 118) | pub fn from_matches(m: &ArgMatches<'_>) -> (Self, u64) {
type FormatType (line 135) | pub enum FormatType {
type Format (line 146) | pub enum Format {
method from_type (line 171) | pub fn from_type(t: FormatType) -> Vec<Self> {
method inner (line 207) | pub fn inner(&self, conf: UserConfig) -> String {
method get_placeholder (line 258) | pub fn get_placeholder(&self) -> &str {
function join_artists (line 162) | pub fn join_artists(a: Vec<SimplifiedArtist>) -> String {
FILE: src/config.rs
constant DEFAULT_PORT (line 10) | const DEFAULT_PORT: u16 = 8888;
constant FILE_NAME (line 11) | const FILE_NAME: &str = "client.yml";
constant CONFIG_DIR (line 12) | const CONFIG_DIR: &str = ".config";
constant APP_CONFIG_DIR (line 13) | const APP_CONFIG_DIR: &str = "spotify-tui";
constant TOKEN_CACHE_FILE (line 14) | const TOKEN_CACHE_FILE: &str = ".spotify_token_cache.json";
type ClientConfig (line 17) | pub struct ClientConfig {
method new (line 31) | pub fn new() -> ClientConfig {
method get_redirect_uri (line 40) | pub fn get_redirect_uri(&self) -> String {
method get_port (line 44) | pub fn get_port(&self) -> u16 {
method get_or_build_paths (line 48) | pub fn get_or_build_paths(&self) -> Result<ConfigPaths> {
method set_device_id (line 77) | pub fn set_device_id(&mut self, device_id: String) -> Result<()> {
method load_config (line 91) | pub fn load_config(&mut self) -> Result<()> {
method get_client_key_from_input (line 159) | fn get_client_key_from_input(type_label: &'static str) -> Result<Strin...
method validate_client_key (line 184) | fn validate_client_key(key: &str) -> Result<()> {
type ConfigPaths (line 25) | pub struct ConfigPaths {
FILE: src/event/events.rs
type EventConfig (line 7) | pub struct EventConfig {
method default (line 15) | fn default() -> EventConfig {
type Event (line 24) | pub enum Event<I> {
type Events (line 33) | pub struct Events {
method new (line 41) | pub fn new(tick_rate: u64) -> Events {
method with_config (line 49) | pub fn with_config(config: EventConfig) -> Events {
method next (line 73) | pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
FILE: src/event/key.rs
type Key (line 6) | pub enum Key {
method from_f (line 78) | pub fn from_f(n: u8) -> Key {
method fmt (line 99) | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
method from (line 124) | fn from(key_event: event::KeyEvent) -> Self {
FILE: src/handlers/album_list.rs
function handler (line 7) | pub fn handler(key: Key, app: &mut App) {
function on_left_press (line 66) | fn on_left_press() {
function on_esc (line 80) | fn on_esc() {
FILE: src/handlers/album_tracks.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
function handle_high_event (line 109) | fn handle_high_event(app: &mut App) {
function handle_middle_event (line 124) | fn handle_middle_event(app: &mut App) {
function handle_low_event (line 143) | fn handle_low_event(app: &mut App) {
function handle_recommended_tracks (line 162) | fn handle_recommended_tracks(app: &mut App) {
function handle_save_event (line 200) | fn handle_save_event(app: &mut App) {
function handle_save_album_event (line 232) | fn handle_save_album_event(app: &mut App) {
function on_left_press (line 256) | fn on_left_press() {
function on_esc (line 270) | fn on_esc() {
FILE: src/handlers/analysis.rs
function handler (line 3) | pub fn handler(_key: Key, _app: &mut App) {}
FILE: src/handlers/artist.rs
function handle_down_press_on_selected_block (line 6) | fn handle_down_press_on_selected_block(app: &mut App) {
function handle_down_press_on_hovered_block (line 35) | fn handle_down_press_on_hovered_block(app: &mut App) {
function handle_up_press_on_selected_block (line 52) | fn handle_up_press_on_selected_block(app: &mut App) {
function handle_up_press_on_hovered_block (line 81) | fn handle_up_press_on_hovered_block(app: &mut App) {
function handle_high_press_on_selected_block (line 98) | fn handle_high_press_on_selected_block(app: &mut App) {
function handle_middle_press_on_selected_block (line 118) | fn handle_middle_press_on_selected_block(app: &mut App) {
function handle_low_press_on_selected_block (line 138) | fn handle_low_press_on_selected_block(app: &mut App) {
function handle_recommend_event_on_selected_block (line 158) | fn handle_recommend_event_on_selected_block(app: &mut App) {
function handle_enter_event_on_selected_block (line 186) | fn handle_enter_event_on_selected_block(app: &mut App) {
function handle_enter_event_on_hovered_block (line 224) | fn handle_enter_event_on_hovered_block(app: &mut App) {
function handler (line 235) | pub fn handler(key: Key, app: &mut App) {
function on_esc (line 328) | fn on_esc() {
FILE: src/handlers/artist_albums.rs
function handler (line 7) | pub fn handler(key: Key, app: &mut App) {
function on_left_press (line 51) | fn on_left_press() {
function on_esc (line 65) | fn on_esc() {
FILE: src/handlers/artists.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/basic_view.rs
function handler (line 4) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/common_key_events.rs
function down_event (line 4) | pub fn down_event(key: Key) -> bool {
function up_event (line 8) | pub fn up_event(key: Key) -> bool {
function left_event (line 12) | pub fn left_event(key: Key) -> bool {
function right_event (line 16) | pub fn right_event(key: Key) -> bool {
function high_event (line 20) | pub fn high_event(key: Key) -> bool {
function middle_event (line 24) | pub fn middle_event(key: Key) -> bool {
function low_event (line 28) | pub fn low_event(key: Key) -> bool {
function on_down_press_handler (line 32) | pub fn on_down_press_handler<T>(selection_data: &[T], selection_index: O...
function on_up_press_handler (line 49) | pub fn on_up_press_handler<T>(selection_data: &[T], selection_index: Opt...
function on_high_press_handler (line 65) | pub fn on_high_press_handler() -> usize {
function on_middle_press_handler (line 69) | pub fn on_middle_press_handler<T>(selection_data: &[T]) -> usize {
function on_low_press_handler (line 77) | pub fn on_low_press_handler<T>(selection_data: &[T]) -> usize {
function handle_right_event (line 81) | pub fn handle_right_event(app: &mut App) {
function handle_left_event (line 143) | pub fn handle_left_event(app: &mut App) {
function test_on_down_press_handler (line 153) | fn test_on_down_press_handler() {
function test_on_up_press_handler (line 168) | fn test_on_up_press_handler() {
FILE: src/handlers/dialog.rs
function handler (line 4) | pub fn handler(key: Key, app: &mut App) {
function handle_playlist_dialog (line 27) | fn handle_playlist_dialog(app: &mut App) {
function handle_playlist_search_dialog (line 31) | fn handle_playlist_search_dialog(app: &mut App) {
FILE: src/handlers/empty.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
function on_enter (line 68) | fn on_enter() {
function on_down_press (line 81) | fn on_down_press() {
function on_up_press (line 96) | fn on_up_press() {
function on_left_press (line 109) | fn on_left_press() {
function on_right_press (line 130) | fn on_right_press() {
FILE: src/handlers/episode_table.rs
function handler (line 9) | pub fn handler(key: Key, app: &mut App) {
function jump_to_end (line 60) | fn jump_to_end(app: &mut App) {
function on_enter (line 67) | fn on_enter(app: &mut App) {
function handle_prev_event (line 82) | fn handle_prev_event(app: &mut App) {
function handle_next_event (line 86) | fn handle_next_event(app: &mut App) {
function handle_follow_event (line 103) | fn handle_follow_event(app: &mut App) {
function handle_unfollow_event (line 107) | fn handle_unfollow_event(app: &mut App) {
function jump_to_start (line 111) | fn jump_to_start(app: &mut App) {
function toggle_sort_by_date (line 115) | fn toggle_sort_by_date(app: &mut App) {
FILE: src/handlers/error_screen.rs
function handler (line 3) | pub fn handler(_key: Key, _app: &mut App) {}
FILE: src/handlers/help_menu.rs
type Direction (line 5) | enum Direction {
function handler (line 10) | pub fn handler(key: Key, app: &mut App) {
function move_page (line 28) | fn move_page(direction: Direction, app: &mut App) {
FILE: src/handlers/home.rs
constant LARGE_SCROLL (line 4) | const LARGE_SCROLL: u16 = 10;
constant SMALL_SCROLL (line 5) | const SMALL_SCROLL: u16 = 1;
function handler (line 7) | pub fn handler(key: Key, app: &mut App) {
function on_small_down_press (line 37) | fn on_small_down_press() {
function on_small_up_press (line 48) | fn on_small_up_press() {
function on_large_down_press (line 67) | fn on_large_down_press() {
function on_large_up_press (line 78) | fn on_large_up_press() {
FILE: src/handlers/input.rs
function handler (line 10) | pub fn handler(key: Key, app: &mut App) {
function process_input (line 97) | fn process_input(app: &mut App, input: String) {
function spotify_resource_id (line 117) | fn spotify_resource_id(base: &str, uri: &str, sep: &str, resource_type: ...
function attempt_process_uri (line 130) | fn attempt_process_uri(app: &mut App, input: &str, base: &str, sep: &str...
function compute_character_width (line 165) | fn compute_character_width(character: char) -> u16 {
function str_to_vec_char (line 176) | fn str_to_vec_char(s: &str) -> Vec<char> {
function test_compute_character_width_with_multiple_characters (line 181) | fn test_compute_character_width_with_multiple_characters() {
function test_input_handler_clear_input_on_ctrl_l (line 188) | fn test_input_handler_clear_input_on_ctrl_l() {
function test_input_handler_ctrl_u (line 199) | fn test_input_handler_ctrl_u() {
function test_input_handler_ctrl_k (line 214) | fn test_input_handler_ctrl_k() {
function test_input_handler_ctrl_w (line 233) | fn test_input_handler_ctrl_w() {
function test_input_handler_esc_back_to_playlist (line 272) | fn test_input_handler_esc_back_to_playlist() {
function test_input_handler_on_enter_text (line 283) | fn test_input_handler_on_enter_text() {
function test_input_handler_backspace (line 296) | fn test_input_handler_backspace() {
function test_input_handler_delete (line 321) | fn test_input_handler_delete() {
function test_input_handler_left_event (line 347) | fn test_input_handler_left_event() {
function test_input_handler_on_enter_text_non_english_char (line 375) | fn test_input_handler_on_enter_text_non_english_char() {
function test_input_handler_on_enter_text_wide_char (line 388) | fn test_input_handler_on_enter_text_wide_char() {
constant URI_BASE (line 405) | const URI_BASE: &str = "spotify:";
constant URL_BASE (line 406) | const URL_BASE: &str = "https://open.spotify.com/";
function check_uri_parse (line 408) | fn check_uri_parse(expected_id: &str, parsed: (String, bool)) {
function run_test_for_id_and_resource_type (line 413) | fn run_test_for_id_and_resource_type(id: &str, resource_type: &str) {
function artist (line 435) | fn artist() {
function album (line 441) | fn album() {
function playlist (line 447) | fn playlist() {
function show (line 453) | fn show() {
function track (line 459) | fn track() {
function invalid_format_doesnt_match (line 465) | fn invalid_format_doesnt_match() {
function parse_with_query_parameters (line 480) | fn parse_with_query_parameters() {
function mismatched_resource_types_do_not_match (line 499) | fn mismatched_resource_types_do_not_match() {
FILE: src/handlers/library.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/made_for_you.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/mod.rs
function handle_app (line 32) | pub fn handle_app(key: Key, app: &mut App) {
function handle_block_events (line 102) | fn handle_block_events(key: Key, app: &mut App) {
function handle_escape (line 174) | fn handle_escape(app: &mut App) {
function handle_jump_to_context (line 198) | fn handle_jump_to_context(app: &mut App) {
function handle_jump_to_album (line 213) | fn handle_jump_to_album(app: &mut App) {
function handle_jump_to_artist_album (line 230) | fn handle_jump_to_artist_album(app: &mut App) {
FILE: src/handlers/playbar.rs
function handler (line 9) | pub fn handler(key: Key, app: &mut App) {
function on_left_press (line 40) | fn on_left_press() {
FILE: src/handlers/playlist.rs
function handler (line 9) | pub fn handler(key: Key, app: &mut App) {
function test (line 94) | fn test() {}
FILE: src/handlers/podcasts.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/recently_played.rs
function handler (line 4) | pub fn handler(key: Key, app: &mut App) {
function on_left_press (line 97) | fn on_left_press() {
function on_esc (line 111) | fn on_esc() {
FILE: src/handlers/search_results.rs
function handle_down_press_on_selected_block (line 11) | fn handle_down_press_on_selected_block(app: &mut App) {
function handle_down_press_on_hovered_block (line 63) | fn handle_down_press_on_hovered_block(app: &mut App) {
function handle_up_press_on_selected_block (line 84) | fn handle_up_press_on_selected_block(app: &mut App) {
function handle_up_press_on_hovered_block (line 136) | fn handle_up_press_on_hovered_block(app: &mut App) {
function handle_high_press_on_selected_block (line 157) | fn handle_high_press_on_selected_block(app: &mut App) {
function handle_middle_press_on_selected_block (line 193) | fn handle_middle_press_on_selected_block(app: &mut App) {
function handle_low_press_on_selected_block (line 229) | fn handle_low_press_on_selected_block(app: &mut App) {
function handle_add_item_to_queue (line 265) | fn handle_add_item_to_queue(app: &mut App) {
function handle_enter_event_on_selected_block (line 286) | fn handle_enter_event_on_selected_block(app: &mut App) {
function handle_enter_event_on_hovered_block (line 349) | fn handle_enter_event_on_hovered_block(app: &mut App) {
function handle_recommended_tracks (line 385) | fn handle_recommended_tracks(app: &mut App) {
function handler (line 420) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/select_device.rs
function handler (line 8) | pub fn handler(key: Key, app: &mut App) {
FILE: src/handlers/track_table.rs
function handler (line 10) | pub fn handler(key: Key, app: &mut App) {
function play_random_song (line 157) | fn play_random_song(app: &mut App) {
function handle_save_track_event (line 256) | fn handle_save_track_event(app: &mut App) {
function handle_recommended_tracks (line 266) | fn handle_recommended_tracks(app: &mut App) {
function jump_to_end (line 278) | fn jump_to_end(app: &mut App) {
function on_enter (line 311) | fn on_enter(app: &mut App) {
function on_queue (line 417) | fn on_queue(app: &mut App) {
function jump_to_start (line 468) | fn jump_to_start(app: &mut App) {
FILE: src/main.rs
constant SCOPES (line 51) | const SCOPES: [&str; 14] = [
function get_token_auto (line 69) | pub async fn get_token_auto(spotify_oauth: &mut SpotifyOAuth, port: u16)...
function close_application (line 88) | fn close_application() -> Result<()> {
function panic_hook (line 95) | fn panic_hook(info: &PanicInfo<'_>) {
function main (line 124) | async fn main() -> Result<()> {
function start_tokio (line 261) | async fn start_tokio<'a>(io_rx: std::sync::mpsc::Receiver<IoEvent>, netw...
function start_ui (line 267) | async fn start_ui(user_config: UserConfig, app: &Arc<Mutex<App>>) -> Res...
FILE: src/network.rs
type IoEvent (line 35) | pub enum IoEvent {
function get_spotify (line 93) | pub fn get_spotify(token_info: TokenInfo) -> (Spotify, SystemTime) {
type Network (line 117) | pub struct Network<'a> {
function new (line 127) | pub fn new(
function handle_network_event (line 144) | pub async fn handle_network_event(&mut self, io_event: IoEvent) {
function handle_error (line 311) | async fn handle_error(&mut self, e: anyhow::Error) {
function get_user (line 316) | async fn get_user(&mut self) {
function get_devices (line 328) | async fn get_devices(&mut self) {
function get_current_playback (line 340) | async fn get_current_playback(&mut self) {
function current_user_saved_tracks_contains (line 380) | async fn current_user_saved_tracks_contains(&mut self, ids: Vec<String>) {
function get_playlist_tracks (line 403) | async fn get_playlist_tracks(&mut self, playlist_id: String, playlist_of...
function set_playlist_tracks_to_table (line 424) | async fn set_playlist_tracks_to_table(&mut self, playlist_track_page: &P...
function set_tracks_to_table (line 437) | async fn set_tracks_to_table(&mut self, tracks: Vec<FullTrack>) {
function set_artists_to_table (line 450) | async fn set_artists_to_table(&mut self, artists: Vec<FullArtist>) {
function get_made_for_you_playlist_tracks (line 455) | async fn get_made_for_you_playlist_tracks(
function get_current_user_saved_shows (line 484) | async fn get_current_user_saved_shows(&mut self, offset: Option<u32>) {
function current_user_saved_shows_contains (line 503) | async fn current_user_saved_shows_contains(&mut self, show_ids: Vec<Stri...
function get_show_episodes (line 520) | async fn get_show_episodes(&mut self, show: Box<SimplifiedShow>) {
function get_show (line 545) | async fn get_show(&mut self, show_id: String) {
function get_current_show_episodes (line 563) | async fn get_current_show_episodes(&mut self, show_id: String, offset: O...
function get_search_results (line 581) | async fn get_search_results(&mut self, search_term: String, country: Opt...
function get_current_user_saved_tracks (line 684) | async fn get_current_user_saved_tracks(&mut self, offset: Option<u32>) {
function start_playback (line 714) | async fn start_playback(
function seek (line 762) | async fn seek(&mut self, position_ms: u32) {
function next_track (line 782) | async fn next_track(&mut self) {
function previous_track (line 797) | async fn previous_track(&mut self) {
function shuffle (line 812) | async fn shuffle(&mut self, shuffle_state: bool) {
function repeat (line 832) | async fn repeat(&mut self, repeat_state: RepeatState) {
function pause_playback (line 855) | async fn pause_playback(&mut self) {
function change_volume (line 870) | async fn change_volume(&mut self, volume_percent: u8) {
function get_artist (line 888) | async fn get_artist(
function get_album_tracks (line 940) | async fn get_album_tracks(&mut self, album: Box<SimplifiedAlbum>) {
function get_recommendations_for_seed (line 972) | async fn get_recommendations_for_seed(
function extract_recommended_tracks (line 1024) | async fn extract_recommended_tracks(
function get_recommendations_for_track_id (line 1045) | async fn get_recommendations_for_track_id(&mut self, id: String, country...
function toggle_save_track (line 1054) | async fn toggle_save_track(&mut self, track_id: String) {
function get_followed_artists (line 1098) | async fn get_followed_artists(&mut self, after: Option<String>) {
function user_artist_check_follow (line 1115) | async fn user_artist_check_follow(&mut self, artist_ids: Vec<String>) {
function get_current_user_saved_albums (line 1128) | async fn get_current_user_saved_albums(&mut self, offset: Option<u32>) {
function current_user_saved_albums_contains (line 1147) | async fn current_user_saved_albums_contains(&mut self, album_ids: Vec<St...
function current_user_saved_album_delete (line 1164) | pub async fn current_user_saved_album_delete(&mut self, album_id: String) {
function current_user_saved_album_add (line 1181) | async fn current_user_saved_album_add(&mut self, album_id: String) {
function current_user_saved_shows_delete (line 1195) | async fn current_user_saved_shows_delete(&mut self, show_id: String) {
function current_user_saved_shows_add (line 1212) | async fn current_user_saved_shows_add(&mut self, show_id: String) {
function user_unfollow_artists (line 1225) | async fn user_unfollow_artists(&mut self, artist_ids: Vec<String>) {
function user_follow_artists (line 1240) | async fn user_follow_artists(&mut self, artist_ids: Vec<String>) {
function user_follow_playlist (line 1255) | async fn user_follow_playlist(
function user_unfollow_playlist (line 1275) | async fn user_unfollow_playlist(&mut self, user_id: String, playlist_id:...
function made_for_you_search_and_add (line 1290) | async fn made_for_you_search_and_add(&mut self, search_string: String, c...
function get_audio_analysis (line 1337) | async fn get_audio_analysis(&mut self, uri: String) {
function get_current_user_playlists (line 1349) | async fn get_current_user_playlists(&mut self) {
function get_recently_played (line 1368) | async fn get_recently_played(&mut self) {
function get_album (line 1393) | async fn get_album(&mut self, album_id: String) {
function get_album_for_track (line 1413) | async fn get_album_for_track(&mut self, track_id: String) {
function transfert_playback_to_device (line 1446) | async fn transfert_playback_to_device(&mut self, device_id: String) {
function refresh_authentication (line 1468) | async fn refresh_authentication(&mut self) {
function add_item_to_queue (line 1480) | async fn add_item_to_queue(&mut self, item: String) {
FILE: src/redirect_uri.rs
function redirect_uri_web_server (line 7) | pub fn redirect_uri_web_server(spotify_oauth: &mut SpotifyOAuth, port: u...
function handle_connection (line 35) | fn handle_connection(mut stream: TcpStream) -> Option<String> {
function respond_with_success (line 60) | fn respond_with_success(mut stream: TcpStream) {
function respond_with_error (line 69) | fn respond_with_error(error_message: String, mut stream: TcpStream) {
FILE: src/ui/audio_analysis.rs
constant PITCHES (line 11) | const PITCHES: [&str; 12] = [
function draw (line 15) | pub fn draw<B>(f: &mut Frame<B>, app: &App)
FILE: src/ui/help.rs
function get_help_docs (line 3) | pub fn get_help_docs(key_bindings: &KeyBindings) -> Vec<Vec<String>> {
FILE: src/ui/mod.rs
type TableId (line 29) | pub enum TableId {
type ColumnId (line 41) | pub enum ColumnId {
method default (line 48) | fn default() -> Self {
type TableHeader (line 53) | pub struct TableHeader<'a> {
function get_index (line 59) | pub fn get_index(&self, id: ColumnId) -> Option<usize> {
type TableHeaderItem (line 65) | pub struct TableHeaderItem<'a> {
type TableItem (line 71) | pub struct TableItem {
function draw_help_menu (line 76) | pub fn draw_help_menu<B>(f: &mut Frame<B>, app: &App)
function draw_input_and_help_box (line 123) | pub fn draw_input_and_help_box<B>(f: &mut Frame<B>, app: &App, layout_ch...
function draw_main_layout (line 179) | pub fn draw_main_layout<B>(f: &mut Frame<B>, app: &App)
function draw_routes (line 225) | pub fn draw_routes<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_library_block (line 283) | pub fn draw_library_block<B>(f: &mut Frame<B>, app: &App, layout_chunk: ...
function draw_playlist_block (line 303) | pub fn draw_playlist_block<B>(f: &mut Frame<B>, app: &App, layout_chunk:...
function draw_user_block (line 330) | pub fn draw_user_block<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_search_results (line 364) | pub fn draw_search_results<B>(f: &mut Frame<B>, app: &App, layout_chunk:...
type AlbumUi (line 547) | struct AlbumUi {
function draw_artist_table (line 553) | pub fn draw_artist_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: R...
function draw_podcast_table (line 591) | pub fn draw_podcast_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: ...
function draw_album_table (line 643) | pub fn draw_album_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_recommendations_table (line 755) | pub fn draw_recommendations_table<B>(f: &mut Frame<B>, app: &App, layout...
function draw_song_table (line 834) | pub fn draw_song_table<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_basic_view (line 902) | pub fn draw_basic_view<B>(f: &mut Frame<B>, app: &App)
function draw_playbar (line 925) | pub fn draw_playbar<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_error_screen (line 1060) | pub fn draw_error_screen<B>(f: &mut Frame<B>, app: &App)
function draw_home (line 1121) | fn draw_home<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_artist_albums (line 1182) | fn draw_artist_albums<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_device_list (line 1286) | pub fn draw_device_list<B>(f: &mut Frame<B>, app: &App)
function draw_album_list (line 1354) | pub fn draw_album_list<B>(f: &mut Frame<B>, app: &App, layout_chunk: Rect)
function draw_show_episodes (line 1418) | pub fn draw_show_episodes<B>(f: &mut Frame<B>, app: &App, layout_chunk: ...
function draw_made_for_you (line 1529) | pub fn draw_made_for_you<B>(f: &mut Frame<B>, app: &App, layout_chunk: R...
function draw_recently_played_table (line 1570) | pub fn draw_recently_played_table<B>(f: &mut Frame<B>, app: &App, layout...
function draw_selectable_list (line 1637) | fn draw_selectable_list<B, S>(
function draw_dialog (line 1675) | fn draw_dialog<B>(f: &mut Frame<B>, app: &App)
function draw_table (line 1752) | fn draw_table<B>(
FILE: src/ui/util.rs
constant BASIC_VIEW_HEIGHT (line 6) | pub const BASIC_VIEW_HEIGHT: u16 = 6;
constant SMALL_TERMINAL_WIDTH (line 7) | pub const SMALL_TERMINAL_WIDTH: u16 = 150;
constant SMALL_TERMINAL_HEIGHT (line 8) | pub const SMALL_TERMINAL_HEIGHT: u16 = 45;
function get_search_results_highlight_state (line 10) | pub fn get_search_results_highlight_state(
function get_artist_highlight_state (line 22) | pub fn get_artist_highlight_state(app: &App, block_to_match: ArtistBlock...
function get_color (line 34) | pub fn get_color((is_active, is_hovered): (bool, bool), theme: Theme) ->...
function create_artist_string (line 42) | pub fn create_artist_string(artists: &[SimplifiedArtist]) -> String {
function millis_to_minutes (line 50) | pub fn millis_to_minutes(millis: u128) -> String {
function display_track_progress (line 66) | pub fn display_track_progress(progress: u128, track_duration: u32) -> St...
function get_percentage_width (line 75) | pub fn get_percentage_width(width: u16, percentage: f32) -> u16 {
function get_track_progress_percentage (line 82) | pub fn get_track_progress_percentage(song_progress_ms: u128, track_durat...
function get_main_layout_margin (line 90) | pub fn get_main_layout_margin(app: &App) -> u16 {
function millis_to_minutes_test (line 103) | fn millis_to_minutes_test() {
function display_track_progress_test (line 113) | fn display_track_progress_test() {
function get_track_progress_percentage_test (line 126) | fn get_track_progress_percentage_test() {
FILE: src/user_config.rs
constant FILE_NAME (line 10) | const FILE_NAME: &str = "config.yml";
constant CONFIG_DIR (line 11) | const CONFIG_DIR: &str = ".config";
constant APP_CONFIG_DIR (line 12) | const APP_CONFIG_DIR: &str = "spotify-tui";
type UserTheme (line 15) | pub struct UserTheme {
type Theme (line 33) | pub struct Theme {
method default (line 53) | fn default() -> Self {
function parse_key (line 75) | fn parse_key(key: String) -> Result<Key> {
function check_reserved_keys (line 115) | fn check_reserved_keys(key: Key) -> Result<()> {
type UserConfigPaths (line 144) | pub struct UserConfigPaths {
type KeyBindingsString (line 149) | pub struct KeyBindingsString {
type KeyBindings (line 179) | pub struct KeyBindings {
type BehaviorConfigString (line 209) | pub struct BehaviorConfigString {
type BehaviorConfig (line 226) | pub struct BehaviorConfig {
type UserConfigString (line 243) | pub struct UserConfigString {
type UserConfig (line 250) | pub struct UserConfig {
method new (line 258) | pub fn new() -> UserConfig {
method get_or_build_paths (line 308) | pub fn get_or_build_paths(&mut self) -> Result<()> {
method load_keybindings (line 335) | pub fn load_keybindings(&mut self, keybindings: KeyBindingsString) -> ...
method load_theme (line 375) | pub fn load_theme(&mut self, theme: UserTheme) -> Result<()> {
method load_behaviorconfig (line 401) | pub fn load_behaviorconfig(&mut self, behavior_config: BehaviorConfigS...
method load_config (line 467) | pub fn load_config(&mut self) -> Result<()> {
method padded_liked_icon (line 501) | pub fn padded_liked_icon(&self) -> String {
function parse_theme_item (line 506) | fn parse_theme_item(theme_item: &str) -> Result<Color> {
function test_parse_key (line 546) | fn test_parse_key() {
function parse_theme_item_test (line 559) | fn parse_theme_item_test() {
function test_reserved_key (line 589) | fn test_reserved_key() {
FILE: src/util.rs
type Event (line 4) | pub enum Event<I> {
type Events (line 11) | pub struct Events {
method new (line 31) | pub fn new() -> Events {
method with_config (line 35) | pub fn with_config(config: Config) -> Events {
method next (line 68) | pub fn next(&self) -> Result<Event<Key>, mpsc::RecvError> {
type Config (line 16) | pub struct Config {
method default (line 22) | fn default() -> Config {
Condensed preview — 60 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (498K chars).
[
{
"path": ".all-contributorsrc",
"chars": 23415,
"preview": "{\n \"files\": [\n \"README.md\"\n ],\n \"imageSize\": 100,\n \"commit\": false,\n \"contributors\": [\n {\n \"login\": \"Rig"
},
{
"path": ".github/FUNDING.yml",
"chars": 84,
"preview": "# These are supported funding model platforms\n\ngithub: Rigellute\npatreon: rigellute\n"
},
{
"path": ".github/dependabot.yml",
"chars": 109,
"preview": "version: 2\nupdates:\n - package-ecosystem: \"cargo\"\n directory: \"/\"\n schedule:\n interval: \"monthly\""
},
{
"path": ".github/workflows/cd.yml",
"chars": 3041,
"preview": "name: Continuous Deployment\n\non:\n push:\n tags:\n - \"v*.*.*\"\n\njobs:\n publish:\n name: Publishing for ${{ matri"
},
{
"path": ".github/workflows/ci.yml",
"chars": 2347,
"preview": "on:\n pull_request:\n push:\n branches: master\n\nname: Continuous Integration\n\njobs:\n # Workaround for making Github A"
},
{
"path": ".gitignore",
"chars": 179,
"preview": "/target\n**/*.rs.bk\n\n.DS_store\n.env\n.spotify_token_cache.json \n.cached_device_id.txt\nspotify-tui.sketch\n\nspt*.txt\n*.snap\n"
},
{
"path": "CHANGELOG.md",
"chars": 18931,
"preview": "# Changelog\n\n## [Unreleased]\n\n- Fix confirmation dialog handling on playlist delete [#910](https://github.com/Rigellute/"
},
{
"path": "Cargo.toml",
"chars": 1019,
"preview": "[package]\nname = \"spotify-tui\"\ndescription = \"A terminal user interface for Spotify\"\nhomepage = \"https://github.com/Rige"
},
{
"path": "LICENSE",
"chars": 1074,
"preview": "MIT License\n\nCopyright (c) 2021 Alexander Keliris\n\nPermission is hereby granted, free of charge, to any person obtaining"
},
{
"path": "README.md",
"chars": 59878,
"preview": "# Spotify TUI\n\n __/_ __ / /___ __(_)\n / ___/ __ \\\\/ __ \\\\/ __/ / /_/ / / "
},
{
"path": "src/cli/clap.rs",
"chars": 11558,
"preview": "use clap::{App, Arg, ArgGroup, SubCommand};\n\nfn device_arg() -> Arg<'static, 'static> {\n Arg::with_name(\"device\")\n ."
},
{
"path": "src/cli/cli_app.rs",
"chars": 18962,
"preview": "use crate::network::{IoEvent, Network};\nuse crate::user_config::UserConfig;\n\nuse super::util::{Flag, Format, FormatType,"
},
{
"path": "src/cli/handle.rs",
"chars": 4541,
"preview": "use crate::network::{IoEvent, Network};\nuse crate::user_config::UserConfig;\n\nuse super::{\n util::{Flag, JumpDirection, "
},
{
"path": "src/cli/mod.rs",
"chars": 195,
"preview": "mod clap;\nmod cli_app;\nmod handle;\nmod util;\n\npub use self::clap::{list_subcommand, play_subcommand, playback_subcommand"
},
{
"path": "src/cli/util.rs",
"chars": 6617,
"preview": "use clap::ArgMatches;\nuse rspotify::{\n model::{\n album::SimplifiedAlbum, artist::FullArtist, artist::SimplifiedArtis"
},
{
"path": "src/config.rs",
"chars": 6022,
"preview": "use super::banner::BANNER;\nuse anyhow::{anyhow, Error, Result};\nuse serde::{Deserialize, Serialize};\nuse std::{\n fs,\n "
},
{
"path": "src/event/events.rs",
"chars": 2017,
"preview": "use crate::event::Key;\nuse crossterm::event;\nuse std::{sync::mpsc, thread, time::Duration};\n\n#[derive(Debug, Clone, Copy"
},
{
"path": "src/event/key.rs",
"chars": 4087,
"preview": "use crossterm::event;\nuse std::fmt;\n\n/// Represents an key.\n#[derive(PartialEq, Eq, Clone, Copy, Hash, Debug)]\npub enum "
},
{
"path": "src/event/mod.rs",
"chars": 80,
"preview": "mod events;\nmod key;\n\npub use self::{\n events::{Event, Events},\n key::Key,\n};\n"
},
{
"path": "src/handlers/album_list.rs",
"chars": 3094,
"preview": "use super::common_key_events;\nuse crate::{\n app::{ActiveBlock, AlbumTableContext, App, RouteId, SelectedFullAlbum},\n e"
},
{
"path": "src/handlers/album_tracks.rs",
"chars": 9409,
"preview": "use super::common_key_events;\nuse crate::{\n app::{AlbumTableContext, App, RecommendationsContext},\n event::Key,\n netw"
},
{
"path": "src/handlers/analysis.rs",
"chars": 81,
"preview": "use crate::{app::App, event::Key};\n\npub fn handler(_key: Key, _app: &mut App) {}\n"
},
{
"path": "src/handlers/artist.rs",
"chars": 11669,
"preview": "use super::common_key_events;\nuse crate::app::{ActiveBlock, App, ArtistBlock, RecommendationsContext, TrackTableContext}"
},
{
"path": "src/handlers/artist_albums.rs",
"chars": 2315,
"preview": "use super::common_key_events;\nuse crate::{\n app::{App, TrackTableContext},\n event::Key,\n};\n\npub fn handler(key: Ke"
},
{
"path": "src/handlers/artists.rs",
"chars": 3083,
"preview": "use super::common_key_events;\nuse crate::{\n app::{ActiveBlock, App, RecommendationsContext, RouteId},\n event::Key,\n n"
},
{
"path": "src/handlers/basic_view.rs",
"chars": 648,
"preview": "use crate::{app::App, event::Key, network::IoEvent};\nuse rspotify::model::{context::CurrentlyPlaybackContext, PlayingIte"
},
{
"path": "src/handlers/common_key_events.rs",
"chars": 5016,
"preview": "use super::super::app::{ActiveBlock, App, RouteId};\nuse crate::event::Key;\n\npub fn down_event(key: Key) -> bool {\n matc"
},
{
"path": "src/handlers/dialog.rs",
"chars": 881,
"preview": "use super::super::app::{ActiveBlock, App, DialogContext};\nuse crate::event::Key;\n\npub fn handler(key: Key, app: &mut App"
},
{
"path": "src/handlers/empty.rs",
"chars": 6005,
"preview": "use super::common_key_events;\nuse crate::{\n app::{ActiveBlock, App},\n event::Key,\n};\n\n// When no block is actively sel"
},
{
"path": "src/handlers/episode_table.rs",
"chars": 4283,
"preview": "use super::{\n super::app::{App, EpisodeTableContext},\n common_key_events,\n};\nuse crate::app::ActiveBlock;\nuse crate::e"
},
{
"path": "src/handlers/error_screen.rs",
"chars": 81,
"preview": "use crate::{app::App, event::Key};\n\npub fn handler(_key: Key, _app: &mut App) {}\n"
},
{
"path": "src/handlers/help_menu.rs",
"chars": 782,
"preview": "use super::common_key_events;\nuse crate::{app::App, event::Key};\n\n#[derive(PartialEq)]\nenum Direction {\n Up,\n Down,\n}\n"
},
{
"path": "src/handlers/home.rs",
"chars": 2403,
"preview": "use super::{super::app::App, common_key_events};\nuse crate::event::Key;\n\nconst LARGE_SCROLL: u16 = 10;\nconst SMALL_SCROL"
},
{
"path": "src/handlers/input.rs",
"chars": 15324,
"preview": "extern crate unicode_width;\n\nuse super::super::app::{ActiveBlock, App, RouteId};\nuse crate::event::Key;\nuse crate::netwo"
},
{
"path": "src/handlers/library.rs",
"chars": 2642,
"preview": "use super::{\n super::app::{ActiveBlock, App, RouteId, LIBRARY_OPTIONS},\n common_key_events,\n};\nuse crate::event::Key;\n"
},
{
"path": "src/handlers/made_for_you.rs",
"chars": 2419,
"preview": "use super::{\n super::app::{App, TrackTableContext},\n common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::"
},
{
"path": "src/handlers/mod.rs",
"chars": 6927,
"preview": "mod album_list;\nmod album_tracks;\nmod analysis;\nmod artist;\nmod artists;\nmod basic_view;\nmod common_key_events;\nmod dial"
},
{
"path": "src/handlers/playbar.rs",
"chars": 1339,
"preview": "use super::{\n super::app::{ActiveBlock, App},\n common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEven"
},
{
"path": "src/handlers/playlist.rs",
"chars": 3002,
"preview": "use super::{\n super::app::{App, DialogContext, TrackTableContext},\n common_key_events,\n};\nuse crate::app::{ActiveBlock"
},
{
"path": "src/handlers/podcasts.rs",
"chars": 2160,
"preview": "use super::common_key_events;\nuse crate::{\n app::{ActiveBlock, App},\n event::Key,\n network::IoEvent,\n};\n\npub fn handl"
},
{
"path": "src/handlers/recently_played.rs",
"chars": 4193,
"preview": "use super::{super::app::App, common_key_events};\nuse crate::{app::RecommendationsContext, event::Key, network::IoEvent};"
},
{
"path": "src/handlers/search_results.rs",
"chars": 20660,
"preview": "use super::{\n super::app::{\n ActiveBlock, App, DialogContext, RecommendationsContext, RouteId, SearchResultBlock,\n "
},
{
"path": "src/handlers/select_device.rs",
"chars": 2482,
"preview": "use super::{\n super::app::{ActiveBlock, App},\n common_key_events,\n};\nuse crate::event::Key;\nuse crate::network::IoEven"
},
{
"path": "src/handlers/track_table.rs",
"chars": 16928,
"preview": "use super::{\n super::app::{App, RecommendationsContext, TrackTable, TrackTableContext},\n common_key_events,\n};\nuse cra"
},
{
"path": "src/main.rs",
"chars": 12465,
"preview": "mod app;\nmod banner;\nmod cli;\nmod config;\nmod event;\nmod handlers;\nmod network;\nmod redirect_uri;\nmod ui;\nmod user_confi"
},
{
"path": "src/network.rs",
"chars": 42385,
"preview": "use crate::app::{\n ActiveBlock, AlbumTableContext, App, Artist, ArtistBlock, EpisodeTableContext, RouteId,\n Scrollable"
},
{
"path": "src/redirect_uri.html",
"chars": 981,
"preview": "<!DOCTYPE html>\n<html lang=\"en\">\n <head>\n <meta charset=\"utf-8\" />\n <title>spotify-tui</title>\n <link\n hr"
},
{
"path": "src/redirect_uri.rs",
"chars": 2005,
"preview": "use rspotify::{oauth2::SpotifyOAuth, util::request_token};\nuse std::{\n io::prelude::*,\n net::{TcpListener, TcpStream},"
},
{
"path": "src/ui/audio_analysis.rs",
"chars": 4285,
"preview": "use super::util;\nuse crate::app::App;\nuse tui::{\n backend::Backend,\n layout::{Constraint, Direction, Layout},\n style:"
},
{
"path": "src/ui/help.rs",
"chars": 7559,
"preview": "use crate::user_config::KeyBindings;\n\npub fn get_help_docs(key_bindings: &KeyBindings) -> Vec<Vec<String>> {\n vec![\n "
},
{
"path": "src/ui/mod.rs",
"chars": 52043,
"preview": "pub mod audio_analysis;\npub mod help;\npub mod util;\nuse super::{\n app::{\n ActiveBlock, AlbumTableContext, App, Artis"
},
{
"path": "src/ui/util.rs",
"chars": 4136,
"preview": "use super::super::app::{ActiveBlock, App, ArtistBlock, SearchResultBlock};\nuse crate::user_config::Theme;\nuse rspotify::"
},
{
"path": "src/user_config.rs",
"chars": 17395,
"preview": "use crate::event::Key;\nuse anyhow::{anyhow, Result};\nuse serde::{Deserialize, Serialize};\nuse std::{\n fs,\n path::{Path"
},
{
"path": "src/util.rs",
"chars": 1800,
"preview": "use std::{io::stdin, sync::mpsc, thread, time::Duration};\nuse termion::{event::Key, input::TermRead};\n\npub enum Event<I>"
}
]
About this extraction
This page contains the full source code of the Rigellute/spotify-tui GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 60 files (464.3 KB), approximately 119.4k tokens, and a symbol index with 440 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.