├── .gitignore ├── .markdownlint.json ├── .swiftformat ├── .swiftlint.yml ├── .vscode └── tasks.json ├── LICENSE.md ├── README.md ├── workflow ├── icon.png ├── images │ └── about │ │ ├── demo-1.png │ │ ├── demo-2.png │ │ ├── demo-3.png │ │ └── demo-4.png ├── info.plist └── ytsearch ├── ytsearch.xcodeproj ├── project.pbxproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist └── xcshareddata │ └── xcschemes │ └── ytsearch.xcscheme └── ytsearch ├── ChannelSearch.swift ├── LiveBroadcastSearch.swift ├── PlaylistSearch.swift ├── Utils.swift ├── VideoSearch.swift └── main.swift /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 9 | *.xcscmblueprint 10 | *.xccheckout 11 | 12 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 13 | build/ 14 | DerivedData/ 15 | *.moved-aside 16 | *.pbxuser 17 | !default.pbxuser 18 | *.mode1v3 19 | !default.mode1v3 20 | *.mode2v3 21 | !default.mode2v3 22 | *.perspectivev3 23 | !default.perspectivev3 24 | 25 | ## Obj-C/Swift specific 26 | *.hmap 27 | 28 | ## App packaging 29 | *.ipa 30 | *.dSYM.zip 31 | *.dSYM 32 | 33 | ## Playgrounds 34 | timeline.xctimeline 35 | playground.xcworkspace 36 | 37 | # Swift Package Manager 38 | # 39 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 40 | # Packages/ 41 | # Package.pins 42 | # Package.resolved 43 | # *.xcodeproj 44 | # 45 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 46 | # hence it is not needed unless you have added a package configuration file to your project 47 | # .swiftpm 48 | 49 | .build/ 50 | 51 | # CocoaPods 52 | # 53 | # We recommend against adding the Pods directory to your .gitignore. However 54 | # you should judge for yourself, the pros and cons are mentioned at: 55 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 56 | # 57 | # Pods/ 58 | # 59 | # Add this line if you want to avoid checking in source code from the Xcode workspace 60 | # *.xcworkspace 61 | 62 | # Carthage 63 | # 64 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 65 | # Carthage/Checkouts 66 | 67 | Carthage/Build/ 68 | 69 | # Accio dependency management 70 | Dependencies/ 71 | .accio/ 72 | 73 | # fastlane 74 | # 75 | # It is recommended to not store the screenshots in the git repo. 76 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 77 | # For more information about the recommended setup visit: 78 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 79 | 80 | fastlane/report.xml 81 | fastlane/Preview.html 82 | fastlane/screenshots/**/*.png 83 | fastlane/test_output 84 | 85 | # Code Injection 86 | # 87 | # After new code Injection tools there's a generated folder /iOSInjectionProject 88 | # https://github.com/johnno1962/injectionforxcode 89 | 90 | iOSInjectionProject/ 91 | 92 | # Local History 93 | .history 94 | 95 | # Visual Studio Code 96 | *.code-workspace 97 | 98 | # Alfred 99 | prefs.plist 100 | 101 | # Desktop Services Store 102 | .DS_Store 103 | 104 | # Miscellaneous 105 | CONTRIBUTE.md 106 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "default": true, 3 | "MD033": { 4 | "allowed_elements": [ "h1", "p", "a", "img" ] 5 | }, 6 | "MD040": false 7 | } -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # SwiftFormat config compliant with Google Swift Style Guide 2 | # https://google.github.io/swift/ 3 | 4 | # Specify version used in a project 5 | 6 | --swiftversion 5.8 7 | 8 | # Rules explicitly required by the guideline 9 | 10 | --rules \ 11 | blankLinesAroundMark, \ 12 | blankLinesAtEndOfScope, \ 13 | blankLinesAtStartOfScope, \ 14 | blankLinesBetweenScopes, \ 15 | braces, \ 16 | consecutiveBlankLines, \ 17 | consecutiveSpaces, \ 18 | duplicateImports, \ 19 | elseOnSameLine, \ 20 | emptyBraces, \ 21 | enumNamespaces, \ 22 | extensionAccessControl, \ 23 | hoistPatternLet, \ 24 | indent, \ 25 | leadingDelimiters, \ 26 | linebreakAtEndOfFile, \ 27 | organizeDeclarations, \ 28 | redundantInit, \ 29 | redundantParens, \ 30 | redundantPattern, \ 31 | redundantRawValues, \ 32 | redundantType, \ 33 | redundantVoidReturnType, \ 34 | semicolons, \ 35 | sortedImports, \ 36 | sortedSwitchCases, \ 37 | spaceAroundBraces, \ 38 | spaceAroundBrackets, \ 39 | spaceAroundComments, \ 40 | spaceAroundGenerics, \ 41 | spaceAroundOperators, \ 42 | spaceAroundParens, \ 43 | spaceInsideBraces, \ 44 | spaceInsideBrackets, \ 45 | spaceInsideComments, \ 46 | spaceInsideGenerics, \ 47 | spaceInsideParens, \ 48 | todos, \ 49 | trailingClosures, \ 50 | trailingCommas, \ 51 | trailingSpace, \ 52 | typeSugar, \ 53 | void, \ 54 | wrap, \ 55 | wrapArguments, \ 56 | wrapAttributes, \ 57 | # 58 | # Additional rules not mentioned in the guideline, but helping to keep the codebase clean 59 | # Quoting the guideline: 60 | # Common themes among the rules in this section are: 61 | # avoid redundancy, avoid ambiguity, and prefer implicitness over explicitness unless being 62 | # explicit improves readability and/or reduces ambiguity. 63 | # 64 | andOperator, \ 65 | isEmpty, \ 66 | redundantBackticks, \ 67 | redundantBreak, \ 68 | redundantExtensionACL, \ 69 | redundantGet, \ 70 | redundantLetError, \ 71 | redundantNilInit, \ 72 | redundantObjc, \ 73 | redundantReturn, \ 74 | redundantSelf, \ 75 | strongifiedSelf 76 | 77 | # Options for basic rules 78 | 79 | --extensionacl on-declarations 80 | --funcattributes prev-line 81 | --indent 2 82 | --maxwidth 100 83 | --typeattributes prev-line 84 | --varattributes prev-line 85 | --wraparguments before-first 86 | --wrapparameters before-first 87 | --wrapcollections before-first 88 | --wrapreturntype if-multiline 89 | --wrapconditions after-first 90 | 91 | # Option for additional rules 92 | 93 | --self init-only 94 | 95 | # Excluded folders 96 | 97 | --exclude Pods,**/UNTESTED_TODO,vendor,fastlane 98 | 99 | # https://github.com/NoemiRozpara/Google-SwiftFormat-Config -------------------------------------------------------------------------------- /.swiftlint.yml: -------------------------------------------------------------------------------- 1 | disabled_rules: # rule identifiers turned on by default to exclude from running 2 | trailing_comma -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Build Xcode Project", 6 | "type": "shell", 7 | "command": "xcodebuild", 8 | "args": [ 9 | "-project", 10 | "ytsearch.xcodeproj", 11 | "-scheme", 12 | "ytsearch", 13 | "-configuration", 14 | "Debug" 15 | ], 16 | "group": { 17 | "kind": "build", 18 | "isDefault": true 19 | }, 20 | "presentation": { 21 | "reveal": "always", 22 | "panel": "new" 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Arthur Pinheiro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

YouTube Search

2 | 3 |

4 | 5 | 6 | 7 | 8 | 9 | 10 |

11 | 12 |

13 | 14 |

15 | 16 | Search YouTube from [Alfred][1]. 17 | 18 | ## Setup 19 | 20 | The workflow requires an API key, which can be requested and set up by following 21 | the steps 1-3 that you can find [here][2]. 22 | 23 | ## Usage 24 | 25 | Search videos via the `yt` keyword, channels via the `ytc` keyword, playlists 26 | via the `ytp` keyword, and live broadcasts via the `ytl` keyword. 27 | 28 | When displaying channels, select one of them and use the `⌘` modifier key to 29 | show its description. 30 | 31 | ## Contribute 32 | 33 | To report a bug or request a feature, please [create an issue][3] or 34 | [submit a pull request][4]. 35 | 36 | [1]:http://www.alfredapp.com/ 37 | [2]:https://developers.google.com/youtube/v3/getting-started#before-you-start 38 | [3]:https://github.com/xilopaint/alfred-youtube/issues 39 | [4]:https://github.com/xilopaint/alfred-youtube/pulls 40 | -------------------------------------------------------------------------------- /workflow/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/icon.png -------------------------------------------------------------------------------- /workflow/images/about/demo-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/images/about/demo-1.png -------------------------------------------------------------------------------- /workflow/images/about/demo-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/images/about/demo-2.png -------------------------------------------------------------------------------- /workflow/images/about/demo-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/images/about/demo-3.png -------------------------------------------------------------------------------- /workflow/images/about/demo-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/images/about/demo-4.png -------------------------------------------------------------------------------- /workflow/info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bundleid 6 | com.xilopaint.alfredapp.youtube 7 | category 8 | Internet 9 | connections 10 | 11 | 16094B46-C66E-4E56-8EA2-35C146C1E1EE 12 | 13 | 14 | destinationuid 15 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 16 | modifiers 17 | 0 18 | modifiersubtext 19 | 20 | vitoclose 21 | 22 | 23 | 24 | 23881008-ABCE-4E07-84B3-40CBD5F5F810 25 | 26 | 27 | destinationuid 28 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 29 | modifiers 30 | 0 31 | modifiersubtext 32 | 33 | vitoclose 34 | 35 | 36 | 37 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 38 | 39 | 40 | destinationuid 41 | FA51DB74-FBD1-4FD7-8357-C186CA92C630 42 | modifiers 43 | 0 44 | modifiersubtext 45 | 46 | vitoclose 47 | 48 | 49 | 50 | 4EED8CAA-4F68-4044-BFC9-E02BE0A95F5C 51 | 52 | 53 | destinationuid 54 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 55 | modifiers 56 | 0 57 | modifiersubtext 58 | 59 | vitoclose 60 | 61 | 62 | 63 | FE9B84D9-0598-4E34-8183-66C015F56248 64 | 65 | 66 | destinationuid 67 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 68 | modifiers 69 | 0 70 | modifiersubtext 71 | 72 | vitoclose 73 | 74 | 75 | 76 | 77 | createdby 78 | Arthur Pinheiro 79 | description 80 | Search YouTube 81 | disabled 82 | 83 | name 84 | YouTube Search 85 | objects 86 | 87 | 88 | config 89 | 90 | alfredfiltersresults 91 | 92 | alfredfiltersresultsmatchmode 93 | 0 94 | argumenttreatemptyqueryasnil 95 | 96 | argumenttrimmode 97 | 0 98 | argumenttype 99 | 0 100 | escaping 101 | 102 102 | keyword 103 | {var:video_search_keyword} 104 | queuedelaycustom 105 | 3 106 | queuedelayimmediatelyinitially 107 | 108 | queuedelaymode 109 | 1 110 | queuemode 111 | 1 112 | runningsubtext 113 | Fetching results... 114 | script 115 | ./ytsearch video "$1" 116 | scriptargtype 117 | 1 118 | scriptfile 119 | YouTube.swift 120 | subtext 121 | 122 | title 123 | Search Videos 124 | type 125 | 11 126 | withspace 127 | 128 | 129 | type 130 | alfred.workflow.input.scriptfilter 131 | uid 132 | 4EED8CAA-4F68-4044-BFC9-E02BE0A95F5C 133 | version 134 | 3 135 | 136 | 137 | config 138 | 139 | alfredfiltersresults 140 | 141 | alfredfiltersresultsmatchmode 142 | 0 143 | argumenttreatemptyqueryasnil 144 | 145 | argumenttrimmode 146 | 0 147 | argumenttype 148 | 0 149 | escaping 150 | 102 151 | keyword 152 | {var:channel_search_keyword} 153 | queuedelaycustom 154 | 3 155 | queuedelayimmediatelyinitially 156 | 157 | queuedelaymode 158 | 1 159 | queuemode 160 | 1 161 | runningsubtext 162 | Fetching results... 163 | script 164 | ./ytsearch channel "$1" 165 | scriptargtype 166 | 1 167 | scriptfile 168 | YouTube.swift 169 | subtext 170 | 171 | title 172 | Search Channels 173 | type 174 | 11 175 | withspace 176 | 177 | 178 | type 179 | alfred.workflow.input.scriptfilter 180 | uid 181 | 23881008-ABCE-4E07-84B3-40CBD5F5F810 182 | version 183 | 3 184 | 185 | 186 | config 187 | 188 | concurrently 189 | 190 | escaping 191 | 0 192 | script 193 | # THESE VARIABLES MUST BE SET. SEE THE ONEUPDATER README FOR AN EXPLANATION OF EACH. 194 | readonly remote_info_plist='https://raw.githubusercontent.com/xilopaint/alfred-youtube/main/workflow/info.plist' 195 | readonly workflow_url='xilopaint/alfred-youtube' 196 | readonly download_type='github_release' 197 | readonly frequency_check='4' 198 | 199 | # FROM HERE ON, CODE SHOULD BE LEFT UNTOUCHED! 200 | function abort { 201 | echo "${1}" >&2 202 | exit 1 203 | } 204 | 205 | function url_exists { 206 | curl --silent --location --output /dev/null --fail --range 0-0 "${1}" 207 | } 208 | 209 | function notification { 210 | local -r notificator="$(find . -type f -name 'notificator')" 211 | 212 | if [[ -f "${notificator}" && "$(/usr/bin/file --brief --mime-type "${notificator}")" == 'text/x-shellscript' ]]; then 213 | "${notificator}" --message "${1}" --title "${alfred_workflow_name}" --subtitle 'A new version is available' 214 | return 215 | fi 216 | 217 | osascript -e "display notification \"${1}\" with title \"${alfred_workflow_name}\" subtitle \"A new version is available\"" 218 | } 219 | 220 | # Local sanity checks 221 | readonly local_info_plist='info.plist' 222 | readonly local_version="$(/usr/libexec/PlistBuddy -c 'print version' "${local_info_plist}")" 223 | 224 | [[ -n "${local_version}" ]] || abort 'You need to set a workflow version in the configuration sheet.' 225 | [[ "${download_type}" =~ ^(direct|page|github_release)$ ]] || abort "'download_type' (${download_type}) needs to be one of 'direct', 'page', or 'github_release'." 226 | [[ "${frequency_check}" =~ ^[0-9]+$ ]] || abort "'frequency_check' (${frequency_check}) needs to be a number." 227 | 228 | # Check for updates 229 | if [[ $(find "${local_info_plist}" -mtime +"${frequency_check}"d) ]]; then 230 | # Remote sanity check 231 | if ! url_exists "${remote_info_plist}"; then 232 | abort "'remote_info_plist' (${remote_info_plist}) appears to not be reachable." 233 | fi 234 | 235 | readonly tmp_file="$(mktemp)" 236 | curl --silent --location --output "${tmp_file}" "${remote_info_plist}" 237 | readonly remote_version="$(/usr/libexec/PlistBuddy -c 'print version' "${tmp_file}")" 238 | rm "${tmp_file}" 239 | 240 | if [[ "${local_version}" == "${remote_version}" ]]; then 241 | touch "${local_info_plist}" # Reset timer by touching local file 242 | exit 0 243 | fi 244 | 245 | if [[ "${download_type}" == 'page' ]]; then 246 | notification 'Opening download page…' 247 | open "${workflow_url}" 248 | exit 0 249 | fi 250 | 251 | readonly download_url="$( 252 | if [[ "${download_type}" == 'github_release' ]]; then 253 | osascript -l JavaScript -e 'function run(argv) { return JSON.parse(argv[0])["assets"].find(asset => asset["browser_download_url"].endsWith(".alfredworkflow"))["browser_download_url"] }' "$(curl --silent "https://api.github.com/repos/${workflow_url}/releases/latest")" 254 | else 255 | echo "${workflow_url}" 256 | fi 257 | )" 258 | 259 | if url_exists "${download_url}"; then 260 | notification 'Downloading and installing…' 261 | readonly download_name="$(basename "${download_url}")" 262 | curl --silent --location --output "${HOME}/Downloads/${download_name}" "${download_url}" 263 | open "${HOME}/Downloads/${download_name}" 264 | else 265 | abort "'workflow_url' (${download_url}) appears to not be reachable." 266 | fi 267 | fi 268 | scriptargtype 269 | 1 270 | scriptfile 271 | 272 | type 273 | 0 274 | 275 | type 276 | alfred.workflow.action.script 277 | uid 278 | FA51DB74-FBD1-4FD7-8357-C186CA92C630 279 | version 280 | 2 281 | 282 | 283 | config 284 | 285 | browser 286 | 287 | skipqueryencode 288 | 289 | skipvarencode 290 | 291 | spaces 292 | 293 | url 294 | 295 | 296 | type 297 | alfred.workflow.action.openurl 298 | uid 299 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 300 | version 301 | 1 302 | 303 | 304 | config 305 | 306 | alfredfiltersresults 307 | 308 | alfredfiltersresultsmatchmode 309 | 0 310 | argumenttreatemptyqueryasnil 311 | 312 | argumenttrimmode 313 | 0 314 | argumenttype 315 | 0 316 | escaping 317 | 102 318 | keyword 319 | {var:playlist_search_keyword} 320 | queuedelaycustom 321 | 3 322 | queuedelayimmediatelyinitially 323 | 324 | queuedelaymode 325 | 1 326 | queuemode 327 | 1 328 | runningsubtext 329 | Fetching results... 330 | script 331 | ./ytsearch playlist "$1" 332 | scriptargtype 333 | 1 334 | scriptfile 335 | YouTube.swift 336 | subtext 337 | 338 | title 339 | Search Playlists 340 | type 341 | 11 342 | withspace 343 | 344 | 345 | type 346 | alfred.workflow.input.scriptfilter 347 | uid 348 | 16094B46-C66E-4E56-8EA2-35C146C1E1EE 349 | version 350 | 3 351 | 352 | 353 | config 354 | 355 | alfredfiltersresults 356 | 357 | alfredfiltersresultsmatchmode 358 | 0 359 | argumenttreatemptyqueryasnil 360 | 361 | argumenttrimmode 362 | 0 363 | argumenttype 364 | 0 365 | escaping 366 | 102 367 | keyword 368 | {var:live_broadcast_search_keyword} 369 | queuedelaycustom 370 | 3 371 | queuedelayimmediatelyinitially 372 | 373 | queuedelaymode 374 | 1 375 | queuemode 376 | 1 377 | runningsubtext 378 | Fetching results... 379 | script 380 | ./ytsearch live "$1" 381 | scriptargtype 382 | 1 383 | scriptfile 384 | YouTube.swift 385 | subtext 386 | 387 | title 388 | Search Live Broadcasts 389 | type 390 | 11 391 | withspace 392 | 393 | 394 | type 395 | alfred.workflow.input.scriptfilter 396 | uid 397 | FE9B84D9-0598-4E34-8183-66C015F56248 398 | version 399 | 3 400 | 401 | 402 | readme 403 | # Setup 404 | 405 | The workflow requires an API key, which can be requested and set up by following the steps 1-3 that you can find [here](https://developers.google.com/youtube/v3/getting-started#before-you-start). 406 | 407 | # Usage 408 | 409 | Search videos via the `yt` keyword: 410 | 411 | ![video search](images/about/demo-1.png) 412 | 413 | Search channels via the `ytc` keyword: 414 | 415 | ![channel search](images/about/demo-2.png) 416 | 417 | When displaying channels, select one of them and use the ⌘ modifier key to show its description. 418 | 419 | Search playlists via the `ytp` keyword: 420 | 421 | ![playlist search](images/about/demo-3.png) 422 | 423 | Search live broadcasts via the `ytl` keyword: 424 | 425 | ![live broadcast search](images/about/demo-4.png) 426 | uidata 427 | 428 | 16094B46-C66E-4E56-8EA2-35C146C1E1EE 429 | 430 | xpos 431 | 30 432 | ypos 433 | 265 434 | 435 | 23881008-ABCE-4E07-84B3-40CBD5F5F810 436 | 437 | xpos 438 | 30 439 | ypos 440 | 145 441 | 442 | 4BA3ADC5-1A29-4031-837C-44A6CBE6FE85 443 | 444 | xpos 445 | 290 446 | ypos 447 | 195 448 | 449 | 4EED8CAA-4F68-4044-BFC9-E02BE0A95F5C 450 | 451 | xpos 452 | 30 453 | ypos 454 | 25 455 | 456 | FA51DB74-FBD1-4FD7-8357-C186CA92C630 457 | 458 | colorindex 459 | 12 460 | note 461 | OneUpdater 462 | xpos 463 | 480 464 | ypos 465 | 195 466 | 467 | FE9B84D9-0598-4E34-8183-66C015F56248 468 | 469 | xpos 470 | 30 471 | ypos 472 | 385 473 | 474 | 475 | userconfigurationconfig 476 | 477 | 478 | config 479 | 480 | default 481 | yt 482 | placeholder 483 | 484 | required 485 | 486 | trim 487 | 488 | 489 | description 490 | 491 | label 492 | Video Search Keyword 493 | type 494 | textfield 495 | variable 496 | video_search_keyword 497 | 498 | 499 | config 500 | 501 | default 502 | ytc 503 | placeholder 504 | 505 | required 506 | 507 | trim 508 | 509 | 510 | description 511 | 512 | label 513 | Channel Search Keyword 514 | type 515 | textfield 516 | variable 517 | channel_search_keyword 518 | 519 | 520 | config 521 | 522 | default 523 | ytp 524 | placeholder 525 | 526 | required 527 | 528 | trim 529 | 530 | 531 | description 532 | 533 | label 534 | Playlist Search Keyword 535 | type 536 | textfield 537 | variable 538 | playlist_search_keyword 539 | 540 | 541 | config 542 | 543 | default 544 | ytl 545 | placeholder 546 | 547 | required 548 | 549 | trim 550 | 551 | 552 | description 553 | 554 | label 555 | Live Broadcast Search Keyword 556 | type 557 | textfield 558 | variable 559 | live_broadcast_search_keyword 560 | 561 | 562 | config 563 | 564 | default 565 | 566 | placeholder 567 | 568 | required 569 | 570 | trim 571 | 572 | 573 | description 574 | Check the About panel on how to get your API key. 575 | label 576 | API Key 577 | type 578 | textfield 579 | variable 580 | api_key 581 | 582 | 583 | config 584 | 585 | default 586 | 20 587 | placeholder 588 | 589 | required 590 | 591 | trim 592 | 593 | 594 | description 595 | Maximum number of results displayed. 596 | label 597 | Max Results 598 | type 599 | textfield 600 | variable 601 | max_results 602 | 603 | 604 | config 605 | 606 | default 607 | relevance 608 | pairs 609 | 610 | 611 | Date 612 | date 613 | 614 | 615 | Rating 616 | rating 617 | 618 | 619 | Relevance 620 | relevance 621 | 622 | 623 | Title 624 | title 625 | 626 | 627 | 628 | description 629 | 630 | label 631 | Sort By 632 | type 633 | popupbutton 634 | variable 635 | order 636 | 637 | 638 | version 639 | 0.4.0 640 | webaddress 641 | https://www.github.com/xilopaint/alfred-youtube 642 | 643 | 644 | -------------------------------------------------------------------------------- /workflow/ytsearch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xilopaint/alfred-youtube/21372412a52512cf70aa08f8529c97f411d6d050/workflow/ytsearch -------------------------------------------------------------------------------- /ytsearch.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8E3913692A06EFF80093F23F /* ChannelSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8E3913682A06EFF80093F23F /* ChannelSearch.swift */; }; 11 | 8EA71AC22A160C3000804904 /* PlaylistSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EA71AC12A160C3000804904 /* PlaylistSearch.swift */; }; 12 | 8EAC81F22A52375900752080 /* LiveBroadcastSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EAC81F12A52375900752080 /* LiveBroadcastSearch.swift */; }; 13 | 8EC4AC7129FFF301005B5DAE /* VideoSearch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC4AC6E29FFF301005B5DAE /* VideoSearch.swift */; }; 14 | 8EC4AC7229FFF301005B5DAE /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC4AC6F29FFF301005B5DAE /* main.swift */; }; 15 | 8EC4AC7329FFF301005B5DAE /* Utils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8EC4AC7029FFF301005B5DAE /* Utils.swift */; }; 16 | /* End PBXBuildFile section */ 17 | 18 | /* Begin PBXCopyFilesBuildPhase section */ 19 | 8EC4AC4D29FF7FB5005B5DAE /* CopyFiles */ = { 20 | isa = PBXCopyFilesBuildPhase; 21 | buildActionMask = 2147483647; 22 | dstPath = /usr/share/man/man1/; 23 | dstSubfolderSpec = 0; 24 | files = ( 25 | ); 26 | runOnlyForDeploymentPostprocessing = 1; 27 | }; 28 | /* End PBXCopyFilesBuildPhase section */ 29 | 30 | /* Begin PBXFileReference section */ 31 | 8E3913682A06EFF80093F23F /* ChannelSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChannelSearch.swift; sourceTree = ""; }; 32 | 8EA71AC12A160C3000804904 /* PlaylistSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlaylistSearch.swift; sourceTree = ""; }; 33 | 8EAC81F12A52375900752080 /* LiveBroadcastSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LiveBroadcastSearch.swift; sourceTree = ""; }; 34 | 8EC4AC4F29FF7FB5005B5DAE /* ytsearch */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = ytsearch; sourceTree = BUILT_PRODUCTS_DIR; }; 35 | 8EC4AC6E29FFF301005B5DAE /* VideoSearch.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VideoSearch.swift; sourceTree = ""; }; 36 | 8EC4AC6F29FFF301005B5DAE /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 2; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 37 | 8EC4AC7029FFF301005B5DAE /* Utils.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Utils.swift; sourceTree = ""; }; 38 | /* End PBXFileReference section */ 39 | 40 | /* Begin PBXFrameworksBuildPhase section */ 41 | 8EC4AC4C29FF7FB5005B5DAE /* Frameworks */ = { 42 | isa = PBXFrameworksBuildPhase; 43 | buildActionMask = 2147483647; 44 | files = ( 45 | ); 46 | runOnlyForDeploymentPostprocessing = 0; 47 | }; 48 | /* End PBXFrameworksBuildPhase section */ 49 | 50 | /* Begin PBXGroup section */ 51 | 8EC4AC4629FF7FB5005B5DAE = { 52 | isa = PBXGroup; 53 | children = ( 54 | 8EC4AC5129FF7FB5005B5DAE /* ytsearch */, 55 | 8EC4AC5029FF7FB5005B5DAE /* Products */, 56 | ); 57 | sourceTree = ""; 58 | }; 59 | 8EC4AC5029FF7FB5005B5DAE /* Products */ = { 60 | isa = PBXGroup; 61 | children = ( 62 | 8EC4AC4F29FF7FB5005B5DAE /* ytsearch */, 63 | ); 64 | name = Products; 65 | sourceTree = ""; 66 | }; 67 | 8EC4AC5129FF7FB5005B5DAE /* ytsearch */ = { 68 | isa = PBXGroup; 69 | children = ( 70 | 8E3913682A06EFF80093F23F /* ChannelSearch.swift */, 71 | 8EC4AC6F29FFF301005B5DAE /* main.swift */, 72 | 8EAC81F12A52375900752080 /* LiveBroadcastSearch.swift */, 73 | 8EC4AC7029FFF301005B5DAE /* Utils.swift */, 74 | 8EA71AC12A160C3000804904 /* PlaylistSearch.swift */, 75 | 8EC4AC6E29FFF301005B5DAE /* VideoSearch.swift */, 76 | ); 77 | path = ytsearch; 78 | sourceTree = ""; 79 | }; 80 | /* End PBXGroup section */ 81 | 82 | /* Begin PBXNativeTarget section */ 83 | 8EC4AC4E29FF7FB5005B5DAE /* ytsearch */ = { 84 | isa = PBXNativeTarget; 85 | buildConfigurationList = 8EC4AC5629FF7FB5005B5DAE /* Build configuration list for PBXNativeTarget "ytsearch" */; 86 | buildPhases = ( 87 | 8EC4AC4B29FF7FB5005B5DAE /* Sources */, 88 | 8EC4AC4C29FF7FB5005B5DAE /* Frameworks */, 89 | 8EC4AC4D29FF7FB5005B5DAE /* CopyFiles */, 90 | ); 91 | buildRules = ( 92 | ); 93 | dependencies = ( 94 | ); 95 | name = ytsearch; 96 | productName = "YouTube Search"; 97 | productReference = 8EC4AC4F29FF7FB5005B5DAE /* ytsearch */; 98 | productType = "com.apple.product-type.tool"; 99 | }; 100 | /* End PBXNativeTarget section */ 101 | 102 | /* Begin PBXProject section */ 103 | 8EC4AC4729FF7FB5005B5DAE /* Project object */ = { 104 | isa = PBXProject; 105 | attributes = { 106 | BuildIndependentTargetsInParallel = 1; 107 | LastSwiftUpdateCheck = 1430; 108 | LastUpgradeCheck = 1430; 109 | TargetAttributes = { 110 | 8EC4AC4E29FF7FB5005B5DAE = { 111 | CreatedOnToolsVersion = 14.3; 112 | LastSwiftMigration = 1430; 113 | }; 114 | }; 115 | }; 116 | buildConfigurationList = 8EC4AC4A29FF7FB5005B5DAE /* Build configuration list for PBXProject "ytsearch" */; 117 | compatibilityVersion = "Xcode 14.0"; 118 | developmentRegion = en; 119 | hasScannedForEncodings = 0; 120 | knownRegions = ( 121 | en, 122 | Base, 123 | ); 124 | mainGroup = 8EC4AC4629FF7FB5005B5DAE; 125 | productRefGroup = 8EC4AC5029FF7FB5005B5DAE /* Products */; 126 | projectDirPath = ""; 127 | projectRoot = ""; 128 | targets = ( 129 | 8EC4AC4E29FF7FB5005B5DAE /* ytsearch */, 130 | ); 131 | }; 132 | /* End PBXProject section */ 133 | 134 | /* Begin PBXSourcesBuildPhase section */ 135 | 8EC4AC4B29FF7FB5005B5DAE /* Sources */ = { 136 | isa = PBXSourcesBuildPhase; 137 | buildActionMask = 2147483647; 138 | files = ( 139 | 8EC4AC7229FFF301005B5DAE /* main.swift in Sources */, 140 | 8EA71AC22A160C3000804904 /* PlaylistSearch.swift in Sources */, 141 | 8E3913692A06EFF80093F23F /* ChannelSearch.swift in Sources */, 142 | 8EC4AC7129FFF301005B5DAE /* VideoSearch.swift in Sources */, 143 | 8EC4AC7329FFF301005B5DAE /* Utils.swift in Sources */, 144 | 8EAC81F22A52375900752080 /* LiveBroadcastSearch.swift in Sources */, 145 | ); 146 | runOnlyForDeploymentPostprocessing = 0; 147 | }; 148 | /* End PBXSourcesBuildPhase section */ 149 | 150 | /* Begin XCBuildConfiguration section */ 151 | 8EC4AC5429FF7FB5005B5DAE /* Debug */ = { 152 | isa = XCBuildConfiguration; 153 | buildSettings = { 154 | ALWAYS_SEARCH_USER_PATHS = NO; 155 | CLANG_ANALYZER_NONNULL = YES; 156 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 157 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 158 | CLANG_ENABLE_MODULES = YES; 159 | CLANG_ENABLE_OBJC_ARC = YES; 160 | CLANG_ENABLE_OBJC_WEAK = YES; 161 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 162 | CLANG_WARN_BOOL_CONVERSION = YES; 163 | CLANG_WARN_COMMA = YES; 164 | CLANG_WARN_CONSTANT_CONVERSION = YES; 165 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 166 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 167 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 168 | CLANG_WARN_EMPTY_BODY = YES; 169 | CLANG_WARN_ENUM_CONVERSION = YES; 170 | CLANG_WARN_INFINITE_RECURSION = YES; 171 | CLANG_WARN_INT_CONVERSION = YES; 172 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 173 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 174 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 175 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 176 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 177 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 178 | CLANG_WARN_STRICT_PROTOTYPES = YES; 179 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 180 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 181 | CLANG_WARN_UNREACHABLE_CODE = YES; 182 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 183 | COPY_PHASE_STRIP = NO; 184 | DEBUG_INFORMATION_FORMAT = dwarf; 185 | ENABLE_STRICT_OBJC_MSGSEND = YES; 186 | ENABLE_TESTABILITY = YES; 187 | GCC_C_LANGUAGE_STANDARD = gnu11; 188 | GCC_DYNAMIC_NO_PIC = NO; 189 | GCC_NO_COMMON_BLOCKS = YES; 190 | GCC_OPTIMIZATION_LEVEL = 0; 191 | GCC_PREPROCESSOR_DEFINITIONS = ( 192 | "DEBUG=1", 193 | "$(inherited)", 194 | ); 195 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 196 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 197 | GCC_WARN_UNDECLARED_SELECTOR = YES; 198 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 199 | GCC_WARN_UNUSED_FUNCTION = YES; 200 | GCC_WARN_UNUSED_VARIABLE = YES; 201 | MACOSX_DEPLOYMENT_TARGET = 13.3; 202 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 203 | MTL_FAST_MATH = YES; 204 | ONLY_ACTIVE_ARCH = YES; 205 | SDKROOT = macosx; 206 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 207 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 208 | }; 209 | name = Debug; 210 | }; 211 | 8EC4AC5529FF7FB5005B5DAE /* Release */ = { 212 | isa = XCBuildConfiguration; 213 | buildSettings = { 214 | ALWAYS_SEARCH_USER_PATHS = NO; 215 | CLANG_ANALYZER_NONNULL = YES; 216 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 217 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 218 | CLANG_ENABLE_MODULES = YES; 219 | CLANG_ENABLE_OBJC_ARC = YES; 220 | CLANG_ENABLE_OBJC_WEAK = YES; 221 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 222 | CLANG_WARN_BOOL_CONVERSION = YES; 223 | CLANG_WARN_COMMA = YES; 224 | CLANG_WARN_CONSTANT_CONVERSION = YES; 225 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 226 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 227 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 228 | CLANG_WARN_EMPTY_BODY = YES; 229 | CLANG_WARN_ENUM_CONVERSION = YES; 230 | CLANG_WARN_INFINITE_RECURSION = YES; 231 | CLANG_WARN_INT_CONVERSION = YES; 232 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 233 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 234 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 235 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 236 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 237 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 238 | CLANG_WARN_STRICT_PROTOTYPES = YES; 239 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 240 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 241 | CLANG_WARN_UNREACHABLE_CODE = YES; 242 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 243 | COPY_PHASE_STRIP = NO; 244 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 245 | ENABLE_NS_ASSERTIONS = NO; 246 | ENABLE_STRICT_OBJC_MSGSEND = YES; 247 | GCC_C_LANGUAGE_STANDARD = gnu11; 248 | GCC_NO_COMMON_BLOCKS = YES; 249 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 250 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 251 | GCC_WARN_UNDECLARED_SELECTOR = YES; 252 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 253 | GCC_WARN_UNUSED_FUNCTION = YES; 254 | GCC_WARN_UNUSED_VARIABLE = YES; 255 | MACOSX_DEPLOYMENT_TARGET = 13.3; 256 | MTL_ENABLE_DEBUG_INFO = NO; 257 | MTL_FAST_MATH = YES; 258 | SDKROOT = macosx; 259 | SWIFT_COMPILATION_MODE = wholemodule; 260 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 261 | }; 262 | name = Release; 263 | }; 264 | 8EC4AC5729FF7FB5005B5DAE /* Debug */ = { 265 | isa = XCBuildConfiguration; 266 | buildSettings = { 267 | CLANG_ENABLE_MODULES = YES; 268 | CODE_SIGN_STYLE = Automatic; 269 | DEVELOPMENT_TEAM = KR9W3CL44A; 270 | ENABLE_HARDENED_RUNTIME = YES; 271 | INFOPLIST_FILE = ""; 272 | PRODUCT_BUNDLE_IDENTIFIER = com.xilopaint.ytsearch; 273 | PRODUCT_NAME = "$(TARGET_NAME)"; 274 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 275 | SWIFT_VERSION = 5.0; 276 | }; 277 | name = Debug; 278 | }; 279 | 8EC4AC5829FF7FB5005B5DAE /* Release */ = { 280 | isa = XCBuildConfiguration; 281 | buildSettings = { 282 | CLANG_ENABLE_MODULES = YES; 283 | CODE_SIGN_STYLE = Automatic; 284 | DEVELOPMENT_TEAM = KR9W3CL44A; 285 | ENABLE_HARDENED_RUNTIME = YES; 286 | INFOPLIST_FILE = ""; 287 | PRODUCT_BUNDLE_IDENTIFIER = com.xilopaint.ytsearch; 288 | PRODUCT_NAME = "$(TARGET_NAME)"; 289 | SWIFT_VERSION = 5.0; 290 | }; 291 | name = Release; 292 | }; 293 | /* End XCBuildConfiguration section */ 294 | 295 | /* Begin XCConfigurationList section */ 296 | 8EC4AC4A29FF7FB5005B5DAE /* Build configuration list for PBXProject "ytsearch" */ = { 297 | isa = XCConfigurationList; 298 | buildConfigurations = ( 299 | 8EC4AC5429FF7FB5005B5DAE /* Debug */, 300 | 8EC4AC5529FF7FB5005B5DAE /* Release */, 301 | ); 302 | defaultConfigurationIsVisible = 0; 303 | defaultConfigurationName = Release; 304 | }; 305 | 8EC4AC5629FF7FB5005B5DAE /* Build configuration list for PBXNativeTarget "ytsearch" */ = { 306 | isa = XCConfigurationList; 307 | buildConfigurations = ( 308 | 8EC4AC5729FF7FB5005B5DAE /* Debug */, 309 | 8EC4AC5829FF7FB5005B5DAE /* Release */, 310 | ); 311 | defaultConfigurationIsVisible = 0; 312 | defaultConfigurationName = Release; 313 | }; 314 | /* End XCConfigurationList section */ 315 | }; 316 | rootObject = 8EC4AC4729FF7FB5005B5DAE /* Project object */; 317 | } 318 | -------------------------------------------------------------------------------- /ytsearch.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /ytsearch.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /ytsearch.xcodeproj/xcshareddata/xcschemes/ytsearch.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /ytsearch/ChannelSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - ChannelStats Struct 4 | 5 | /// A struct representing statistics for a YouTube channel. 6 | struct ChannelStats { 7 | let subscriberCount: Int 8 | let viewCount: Int 9 | let videoCount: Int 10 | } 11 | 12 | // MARK: - JSON Parsing Functions 13 | 14 | /// Parses and processes channel information from the YouTube API's JSON response. 15 | /// 16 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 17 | /// channel snippets. 18 | /// - Returns: An array of dictionaries containing formatted channel information compatible with 19 | /// Alfred. 20 | func parseChannelSnippetJSON(_ json: [String: Any]) -> [[String: Any]] { 21 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { 22 | fputs("Error: Unable to get items from JSON.", stderr) 23 | exit(1) 24 | } 25 | 26 | var parsedSnippetItems: [[String: Any]] = [] 27 | 28 | for item: [String: Any] in items { 29 | if let id: [String: String] = item["id"] as? [String: String], 30 | let channelId: String = id["channelId"], 31 | let snippet: [String: Any] = item["snippet"] as? [String: Any], 32 | let rawTitle: String = snippet["title"] as? String, 33 | let publishedAt: String = snippet["publishedAt"] as? String, 34 | let description: String = snippet["description"] as? String { 35 | let title: String = decodeHTMLEntities(rawTitle) 36 | let elapsedTime: String = parseElapsedTime(from: publishedAt) ?? "Unknown time" 37 | 38 | let parsedSnippetItem: [String: Any] = [ 39 | "channelId": channelId, 40 | "title": title, 41 | "elapsedTime": elapsedTime, 42 | "description": description, 43 | ] 44 | parsedSnippetItems.append(parsedSnippetItem) 45 | } 46 | } 47 | 48 | return parsedSnippetItems 49 | } 50 | 51 | /// Parses the JSON response from the YouTube API containing channel statistics and extracts 52 | /// subscriber counts, view counts, and video counts. 53 | /// 54 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 55 | /// channel statistics. 56 | /// - Returns: A dictionary with video IDs as keys and `ChannelStats` instances containing the 57 | /// subscriber count, view count, and video count as values. 58 | func parseChannelStatisticsJSON(_ json: [String: Any]) -> [String: ChannelStats] { 59 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { return [:] } 60 | 61 | var channelStats: [String: ChannelStats] = [:] 62 | 63 | for item: [String: Any] in items { 64 | if let id: String = item["id"] as? String, 65 | let statistics: [String: Any] = item["statistics"] as? [String: Any], 66 | let subCount = Int(statistics["subscriberCount"] as? String ?? "0"), 67 | let viewCount = Int(statistics["viewCount"] as? String ?? "0"), 68 | let videoCount = Int(statistics["videoCount"] as? String ?? "0") { 69 | channelStats[id] = ChannelStats( 70 | subscriberCount: subCount, 71 | viewCount: viewCount, 72 | videoCount: videoCount 73 | ) 74 | } 75 | } 76 | 77 | return channelStats 78 | } 79 | 80 | // MARK: - Alfred Feedback Generation 81 | 82 | /// Combines the results of `parseChannelSnippetJSON` and `parseChannelStatisticsJSON` into a 83 | /// dictionary formatted according to Alfred's feedback format. 84 | /// 85 | /// - Parameters: 86 | /// - items: An array of dictionaries in the format returned by `parseChannelSnippetJSON`. 87 | /// - channelStats: A dictionary with video IDs as keys and `ChannelStats` instances containing the 88 | /// subscriber count, view count, and video count as values returned by 89 | /// `parseChannelStatisticsJSON`. 90 | /// - Returns: A dictionary containing an `items` key with an array of dictionaries in the format 91 | /// expected by Alfred. 92 | func createAlfredChannelItems( 93 | from items: [[String: Any]], 94 | with channelStats: [String: ChannelStats] 95 | ) -> [String: [[String: Any]]] { 96 | var alfredItems: [[String: Any]] = [] 97 | 98 | for item: [String: Any] in items { 99 | let channelId: String = item["channelId"] as? String ?? "" 100 | let title: String = item["title"] as? String ?? "" 101 | let stats: ChannelStats = channelStats[channelId] ?? 102 | ChannelStats(subscriberCount: 0, viewCount: 0, videoCount: 0) 103 | let subCount: String = formatCount(stats.subscriberCount) 104 | let viewCount: String = formatCount(stats.viewCount) 105 | let videoCount: String = formatCount(stats.videoCount) 106 | let elapsedTime: String = item["elapsedTime"] as? String ?? "" 107 | let description: String = item["description"] as? String ?? "" 108 | 109 | let alfredItem: [String: Any] = [ 110 | "title": title, 111 | "subtitle": "\(subCount) subscribers • \(viewCount) views • \(videoCount) videos • created \(elapsedTime)", 112 | "arg": "https://www.youtube.com/channel/\(channelId)", 113 | "mods": [ 114 | "cmd": [ 115 | "subtitle": "\(description)", 116 | ], 117 | ], 118 | ] 119 | 120 | alfredItems.append(alfredItem) 121 | } 122 | 123 | return ["items": alfredItems] 124 | } 125 | 126 | // MARK: - Response Handling 127 | 128 | /// Handles the response from the YouTube API's search endpoint, sends a request to the channels 129 | /// endpoint, and prints the resulting Alfred feedback. 130 | /// 131 | /// - Parameter apiKey: The YouTube API key used to authenticate the request. 132 | /// - Returns: A closure that takes `Data?`, `URLResponse?`, and `Error?` as arguments. 133 | func handleChannelResponse(apiKey: String) -> (Data?, URLResponse?, Error?) -> Void { 134 | { data, _, error in 135 | // Check for network errors. 136 | guard let data: Data = data, error == nil else { 137 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error.")", stderr) 138 | exit(1) 139 | } 140 | 141 | // Parse the JSON object into a dictionary, or display an error message if unsuccessful. 142 | guard let json: [String: Any] = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 143 | else { 144 | fputs(".\nError: Unable to parse JSON.", stderr) 145 | exit(1) 146 | } 147 | 148 | // Check if there's an error in the API response. 149 | if let apiError: [String: Any] = json["error"] as? [String: Any] { 150 | handleAPIError(apiError) 151 | } else { 152 | let items: [[String: Any]] = parseChannelSnippetJSON(json) 153 | let channelIds: String = items.compactMap { $0["channelId"] as? String } 154 | .joined(separator: ",") 155 | 156 | let endpoint = "https://www.googleapis.com/youtube/v3/channels" 157 | let queryParams: [String: String] = ["part": "statistics", "id": channelIds, "key": apiKey] 158 | 159 | guard let url: URL = buildURL(with: endpoint, using: queryParams) else { 160 | fputs("Error: Unable to build URL.", stderr) 161 | exit(1) 162 | } 163 | 164 | URLSession.shared.dataTask(with: url) { data, _, error in 165 | guard let data: Data = data, error == nil else { 166 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error")", stderr) 167 | exit(1) 168 | } 169 | 170 | guard let json: [String: Any] = try? JSONSerialization 171 | .jsonObject(with: data) as? [String: Any] else { 172 | fputs(".\nError: Unable to parse JSON.", stderr) 173 | exit(1) 174 | } 175 | 176 | let channelStats: [String: ChannelStats] = parseChannelStatisticsJSON(json) 177 | 178 | let alfredItems: [String: [[String: Any]]] = createAlfredChannelItems( 179 | from: items, 180 | with: channelStats 181 | ) 182 | 183 | do { 184 | let alfredFeedback: Data = try serializeJSON(alfredItems) 185 | print(String(data: alfredFeedback, encoding: .utf8)!) 186 | exit(0) 187 | } catch { 188 | fputs(".\nError: Unable to serialize JSON.", stderr) 189 | exit(1) 190 | } 191 | }.resume() 192 | } 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /ytsearch/LiveBroadcastSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - JSON Parsing Functions 4 | 5 | /// Parses and processes live broadcast information from the YouTube API's JSON response. 6 | /// 7 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 8 | /// live broadcast snippets. 9 | /// - Returns: An array of dictionaries containing formatted live broadcast information compatible with 10 | /// Alfred. 11 | func parseLiveBroadcastSnippetJSON(_ json: [String: Any]) -> [[String: Any]] { 12 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { 13 | fputs("Error: Unable to get items from JSON.", stderr) 14 | exit(1) 15 | } 16 | 17 | var parsedSnippetItems: [[String: Any]] = [] 18 | 19 | for item: [String: Any] in items { 20 | if let id: [String: String] = item["id"] as? [String: String], 21 | let videoId: String = id["videoId"], 22 | let snippet: [String: Any] = item["snippet"] as? [String: Any], 23 | let rawTitle: String = snippet["title"] as? String, 24 | let rawChannelTitle: String = snippet["channelTitle"] as? String, 25 | let publishedAt: String = snippet["publishedAt"] as? String { 26 | let title: String = decodeHTMLEntities(rawTitle) 27 | let channelTitle: String = decodeHTMLEntities(rawChannelTitle) 28 | let elapsedTime: String = parseElapsedTime(from: publishedAt) ?? "Unknown time" 29 | 30 | let parsedSnippetItem: [String: String] = [ 31 | "videoId": videoId, 32 | "title": title, 33 | "channelTitle": channelTitle, 34 | "elapsedTime": elapsedTime, 35 | ] 36 | parsedSnippetItems.append(parsedSnippetItem) 37 | } 38 | } 39 | 40 | return parsedSnippetItems 41 | } 42 | 43 | /// Parses the JSON response from the YouTube API containing live streaming details and extracts 44 | /// concurrent viewer counts. 45 | /// 46 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 47 | /// live streaming details. 48 | /// - Returns: A dictionary with video IDs as keys and viewer counts as values. 49 | func parseLiveStreamingDetailsJSON(_ json: [String: Any]) -> [String: Int] { 50 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { return [:] } 51 | 52 | var viewerCounts: [String: Int] = [:] 53 | 54 | for item: [String: Any] in items { 55 | if let id: String = item["id"] as? String, 56 | let liveStreamingDetails: [String: Any] = item["liveStreamingDetails"] as? [String: Any], 57 | let viewerCount = Int(liveStreamingDetails["concurrentViewers"] as? String ?? "0") { 58 | viewerCounts[id] = viewerCount 59 | } 60 | } 61 | 62 | return viewerCounts 63 | } 64 | 65 | // MARK: - Alfred Feedback Generation 66 | 67 | /// Combines the results of `parseLiveBroadcastSnippetJSON` and `parseLiveStreamingDetailsJSON` into a 68 | /// dictionary formatted according to Alfred's feedback format. 69 | /// 70 | /// - Parameters: 71 | /// - items: An array of dictionaries in the format returned by `parseLiveBroadcastSnippetJSON`. 72 | /// - viewerCounts: A dictionary with video IDs as keys and viewer counts as values returned by 73 | /// `parseLiveStreamingDetailsJSON`. 74 | /// - Returns: A dictionary containing an `items` key with an array of dictionaries in the format 75 | /// expected by Alfred. 76 | func createAlfredLiveBroadcastItems( 77 | from items: [[String: Any]], 78 | with viewerCounts: [String: Int] 79 | ) -> [String: [[String: Any]]] { 80 | var alfredItems: [[String: Any]] = [] 81 | 82 | for item: [String: Any] in items { 83 | guard let videoId: String = item["videoId"] as? String, 84 | let channelTitle: String = item["channelTitle"] as? String, 85 | let elapsedTime: String = item["elapsedTime"] as? String, 86 | let title: String = item["title"] as? String 87 | else { 88 | continue 89 | } 90 | 91 | let arg = "https://www.youtube.com/watch?v=\(videoId)" 92 | let rawViewerCount: Int = viewerCounts[videoId] ?? 0 93 | let viewerCount: String = formatCount(rawViewerCount) 94 | let subtitle = "\(channelTitle) • \(viewerCount) watching now • Started streaming \(elapsedTime)" 95 | 96 | let alfredItem: [String: Any] = [ 97 | "videoId": videoId, 98 | "title": title, 99 | "subtitle": subtitle, 100 | "arg": arg, 101 | ] 102 | alfredItems.append(alfredItem) 103 | } 104 | 105 | return ["items": alfredItems] 106 | } 107 | 108 | // MARK: - Response Handling 109 | 110 | /// Handles the response from the YouTube API's search endpoint, sends a request to the videos 111 | /// endpoint, and prints the resulting Alfred feedback for live broadcasts. 112 | /// 113 | /// - Parameter apiKey: The YouTube API key used to authenticate the request. 114 | /// - Returns: A closure that takes `Data?`, `URLResponse?`, and `Error?` as arguments. 115 | func handleLiveBroadcastResponse(apiKey: String) -> (Data?, URLResponse?, Error?) -> Void { 116 | { data, _, error in 117 | guard let data: Data = data, error == nil else { 118 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error.")", stderr) 119 | exit(1) 120 | } 121 | 122 | guard let json: [String: Any] = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 123 | else { 124 | fputs(".\nError: Unable to parse JSON.", stderr) 125 | exit(1) 126 | } 127 | 128 | if let apiError: [String: Any] = json["error"] as? [String: Any] { 129 | handleAPIError(apiError) 130 | } else { 131 | let items: [[String: Any]] = parseLiveBroadcastSnippetJSON(json) 132 | let videoIds: String = items.compactMap { $0["videoId"] as? String }.joined(separator: ",") 133 | 134 | let endpoint = "https://www.googleapis.com/youtube/v3/videos" 135 | let queryParams: [String: String] = ["part": "liveStreamingDetails", "id": videoIds, "key": apiKey] 136 | 137 | guard let url: URL = buildURL(with: endpoint, using: queryParams) else { 138 | fputs("Error: Unable to build URL.", stderr) 139 | exit(1) 140 | } 141 | 142 | URLSession.shared.dataTask(with: url) { data, _, error in 143 | guard let data: Data = data, error == nil else { 144 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error")", stderr) 145 | exit(1) 146 | } 147 | 148 | guard let json: [String: Any] = try? JSONSerialization 149 | .jsonObject(with: data) as? [String: Any] else { 150 | fputs(".\nError: Unable to parse JSON.", stderr) 151 | exit(1) 152 | } 153 | 154 | let viewerCounts: [String: Int] = parseLiveStreamingDetailsJSON(json) 155 | 156 | let alfredItems: [String: [[String: Any]]] = createAlfredLiveBroadcastItems( 157 | from: items, 158 | with: viewerCounts 159 | ) 160 | 161 | do { 162 | let alfredFeedback: Data = try serializeJSON(alfredItems) 163 | print(String(data: alfredFeedback, encoding: .utf8)!) 164 | exit(0) 165 | } catch { 166 | fputs(".\nError: Unable to serialize JSON.", stderr) 167 | exit(1) 168 | } 169 | }.resume() 170 | } 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /ytsearch/PlaylistSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - JSON Parsing Functions 4 | 5 | /// Parses and processes playlist information from the YouTube API's JSON response. 6 | /// 7 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 8 | /// playlist snippets. 9 | /// - Returns: An array of dictionaries containing formatted video information compatible with 10 | /// Alfred. 11 | func parsePlaylistSnippetJSON(_ json: [String: Any]) -> [[String: Any]] { 12 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { 13 | fputs("Error: Unable to get items from JSON.", stderr) 14 | exit(1) 15 | } 16 | 17 | var parsedSnippetItems: [[String: Any]] = [] 18 | 19 | for item: [String: Any] in items { 20 | if let id: [String: String] = item["id"] as? [String: String], 21 | let playlistId: String = id["playlistId"], 22 | let snippet: [String: Any] = item["snippet"] as? [String: Any], 23 | let rawTitle: String = snippet["title"] as? String, 24 | let rawChannelTitle: String = snippet["channelTitle"] as? String, 25 | let publishedAt: String = snippet["publishedAt"] as? String { 26 | let title: String = decodeHTMLEntities(rawTitle) 27 | let channelTitle: String = decodeHTMLEntities(rawChannelTitle) 28 | let elapsedTime: String = parseElapsedTime(from: publishedAt) ?? "Unknown time" 29 | 30 | // Create a result item with playlist and channel information 31 | let parsedSnippetItem: [String: String] = [ 32 | "playlistId": playlistId, 33 | "title": title, 34 | "channelTitle": channelTitle.isEmpty ? "YouTube Music" : channelTitle, 35 | "elapsedTime": elapsedTime, 36 | ] 37 | parsedSnippetItems.append(parsedSnippetItem) 38 | } 39 | } 40 | 41 | return parsedSnippetItems 42 | } 43 | 44 | // MARK: - Alfred Feedback Generation 45 | 46 | /// Converts the results of `parsePlaylistSnippetJSON` into a dictionary in Alfred's feedback 47 | /// format. 48 | /// 49 | /// - Parameters: 50 | /// - items: An array of dictionaries in the format returned by `parsePlaylistSnippetJSON`. 51 | /// - Returns: A dictionary containing an `items` key with an array of dictionaries in the format 52 | /// expected by Alfred. 53 | func createAlfredPlaylistItems(from items: [[String: Any]]) -> [String: [[String: Any]]] { 54 | var alfredItems: [[String: Any]] = [] 55 | 56 | for item: [String: Any] in items { 57 | guard let playlistId: String = item["playlistId"] as? String, 58 | let channelTitle: String = item["channelTitle"] as? String, 59 | let elapsedTime: String = item["elapsedTime"] as? String, 60 | let title: String = item["title"] as? String 61 | else { 62 | continue 63 | } 64 | 65 | let arg = "https://www.youtube.com/playlist?list=\(playlistId)" 66 | let subtitle = "\(channelTitle) • \(elapsedTime)" 67 | 68 | let alfredItem: [String: Any] = [ 69 | "playlistId": playlistId, 70 | "title": title, 71 | "subtitle": subtitle, 72 | "arg": arg, 73 | ] 74 | alfredItems.append(alfredItem) 75 | } 76 | 77 | return ["items": alfredItems] 78 | } 79 | 80 | // MARK: - Response Handling 81 | 82 | /// Handles the response from the YouTube API's search endpoint and prints the resulting Alfred 83 | /// feedback. 84 | /// 85 | /// - Parameter apiKey: The YouTube API key used to authenticate the request. 86 | /// - Returns: A closure that takes `Data?`, `URLResponse?`, and `Error?` as arguments. 87 | func handlePlaylistResponse(apiKey: String) -> (Data?, URLResponse?, Error?) -> Void { 88 | { data, _, error in 89 | // Check for network errors. 90 | guard let data: Data = data, error == nil else { 91 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error.")", stderr) 92 | exit(1) 93 | } 94 | fputs( 95 | "Raw JSON Response: \(String(data: data, encoding: .utf8) ?? "Unable to decode data.")", 96 | stderr 97 | ) 98 | 99 | // Parse the JSON object into a dictionary, or display an error message if unsuccessful. 100 | guard let json: [String: Any] = try? JSONSerialization 101 | .jsonObject(with: data) as? [String: Any] 102 | else { 103 | fputs(".\nError: Unable to parse JSON.", stderr) 104 | exit(1) 105 | } 106 | fputs("Parsed JSON: \(json)", stderr) 107 | 108 | // Check if there's an error in the API response. 109 | if let apiError: [String: Any] = json["error"] as? [String: Any] { 110 | handleAPIError(apiError) 111 | } else { 112 | let items: [[String: Any]] = parsePlaylistSnippetJSON(json) 113 | 114 | let alfredItems: [String: [[String: Any]]] = createAlfredPlaylistItems(from: items) 115 | 116 | do { 117 | let alfredFeedback: Data = try serializeJSON(alfredItems) 118 | print(String(data: alfredFeedback, encoding: .utf8)!) 119 | exit(0) 120 | } catch { 121 | fputs(".\nError: Unable to serialize JSON.", stderr) 122 | exit(1) 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /ytsearch/Utils.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - URL Building 4 | 5 | /// Builds a URL with the specified endpoint and query parameters. 6 | /// 7 | /// - Parameters: 8 | /// - endpoint: The API endpoint as a string. 9 | /// - queryParams: A dictionary of query parameters where the key is the parameter name and the 10 | /// value is the parameter value. 11 | /// - Returns: An optional `URL` constructed using the given endpoint and query parameters. 12 | func buildURL(with endpoint: String, using queryParams: [String: String]) -> URL? { 13 | guard var components = URLComponents(string: endpoint) else { return nil } 14 | components.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) } 15 | return components.url 16 | } 17 | 18 | // MARK: - Text Processing 19 | 20 | /// Decodes HTML entities in the given string. 21 | /// 22 | /// - Parameter rawEntity: A string containing HTML entities. 23 | /// - Returns: A new string with HTML entities replaced with their corresponding characters. 24 | func decodeHTMLEntities(_ rawEntity: String) -> String { 25 | guard let processedEntity: CFString = CFXMLCreateStringByUnescapingEntities( 26 | nil, 27 | rawEntity as CFString, 28 | nil 29 | ) else { 30 | return rawEntity as String 31 | } 32 | return processedEntity as String 33 | } 34 | 35 | /// Formats a given count as a readable string with appropriate suffixes. 36 | /// 37 | /// - Parameter count: An integer representing the count to be formatted. 38 | /// - Returns: A formatted string representing the count with appropriate suffixes. 39 | func formatCount(_ count: Int) -> String { 40 | let formatter = NumberFormatter() 41 | formatter.numberStyle = .decimal 42 | formatter.maximumFractionDigits = 1 43 | formatter.locale = Locale(identifier: "en_US") 44 | 45 | if count >= 1_000_000_000 { 46 | let billions = Double(count) / 1_000_000_000.0 47 | let formatted: String = formatter.string(from: NSNumber(value: billions)) ?? String(billions) 48 | return "\(formatted)B" 49 | } else if count >= 1_000_000 { 50 | let millions = Double(count) / 1_000_000.0 51 | let formatted: String = formatter.string(from: NSNumber(value: millions)) ?? String(millions) 52 | return "\(formatted)M" 53 | } else if count >= 1000 { 54 | let thousands = Double(count) / 1000.0 55 | let formatted: String = formatter.string(from: NSNumber(value: thousands)) ?? String(thousands) 56 | return "\(formatted)K" 57 | } else { 58 | return "\(count)" 59 | } 60 | } 61 | 62 | /// Parses the elapsed time since a video or channel was published into a human-readable string. 63 | /// 64 | /// - Parameter publishedAt: The video or channel published date in ISO 8601 format. 65 | /// - Returns: An optional string representing the elapsed time since the video or channel was 66 | /// published, or `nil` if the input is not valid. 67 | func parseElapsedTime(from publishedAt: String) -> String? { 68 | let dateFormatter = DateFormatter() 69 | dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" 70 | dateFormatter.locale = Locale(identifier: "en_US_POSIX") 71 | 72 | guard let date: Date = dateFormatter.date(from: publishedAt) else { return nil } 73 | 74 | let calendar = Calendar.current 75 | let dateComponents: DateComponents = calendar.dateComponents( 76 | [.year, .month, .day, .hour, .minute], 77 | from: date, 78 | to: Date() 79 | ) 80 | 81 | if let years: Int = dateComponents.year, years > 0 { 82 | return "\(years) year\(years > 1 ? "s" : "") ago" 83 | } else if let months: Int = dateComponents.month, months > 0 { 84 | return "\(months) month\(months > 1 ? "s" : "") ago" 85 | } else if let days: Int = dateComponents.day, days > 0 { 86 | if days >= 14, days <= 31 { 87 | let weeks: Int = days / 7 88 | return "\(weeks) week\(weeks > 1 ? "s" : "") ago" 89 | } 90 | return "\(days) day\(days > 1 ? "s" : "") ago" 91 | } else if let hours: Int = dateComponents.hour, hours > 0 { 92 | return "\(hours) hour\(hours > 1 ? "s" : "") ago" 93 | } else if let minutes: Int = dateComponents.minute, minutes > 0 { 94 | return "\(minutes) minute\(minutes > 1 ? "s" : "") ago" 95 | } 96 | 97 | return "Just now" 98 | } 99 | 100 | // MARK: - JSON Handling 101 | 102 | /// Serializes the provided `alfredItems` dictionary into JSON data. 103 | /// 104 | /// - Parameter alfredItems: A dictionary containing items in the format expected by Alfred. 105 | /// - Throws: If the provided dictionary cannot be serialized into JSON data. 106 | /// - Returns: The JSON data representation of `alfredItems`. 107 | func serializeJSON(_ alfredItems: [String: [[String: Any]]]) throws -> Data { 108 | try JSONSerialization.data(withJSONObject: alfredItems, options: .prettyPrinted) 109 | } 110 | 111 | // MARK: - Error Handling 112 | 113 | /// Handles API error responses by extracting the relevant error information and displaying it. 114 | /// 115 | /// - Parameter errorInfo: A dictionary containing error information. 116 | func handleAPIError(_ errorInfo: [String: Any]) { 117 | if let code: Int = errorInfo["code"] as? Int, 118 | let rawMessage: String = errorInfo["message"] as? String, 119 | let errors: [[String: Any]] = errorInfo["errors"] as? [[String: Any]], 120 | let firstError: [String: Any] = errors.first { 121 | let message: String = rawMessage.replacingOccurrences( 122 | of: "<[^>]+>", 123 | with: "", 124 | options: .regularExpression 125 | ) 126 | let reason: String = firstError["reason"] as? String ?? "Unknown reason" 127 | let extendedHelp: String = firstError["extendedHelp"] as? String ?? "No extended help available" 128 | fputs( 129 | ".\nError Code: \(code)\nMessage: \(message)\nReason: \(reason)\nExtended Help: \(extendedHelp)", 130 | stderr 131 | ) 132 | } else { 133 | fputs(".\nAPI Error: Unable to parse error information.", stderr) 134 | } 135 | 136 | exit(1) 137 | } 138 | -------------------------------------------------------------------------------- /ytsearch/VideoSearch.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | // MARK: - JSON Parsing Functions 4 | 5 | /// Parses and processes video information from the YouTube API's JSON response. 6 | /// 7 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 8 | /// video snippets. 9 | /// - Returns: An array of dictionaries containing formatted video information compatible with 10 | /// Alfred. 11 | func parseVideoSnippetJSON(_ json: [String: Any]) -> [[String: Any]] { 12 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { 13 | fputs("Error: Unable to get items from JSON.", stderr) 14 | exit(1) 15 | } 16 | 17 | var parsedSnippetItems: [[String: Any]] = [] 18 | 19 | for item: [String: Any] in items { 20 | if let id: [String: String] = item["id"] as? [String: String], 21 | let videoId: String = id["videoId"], 22 | let snippet: [String: Any] = item["snippet"] as? [String: Any], 23 | let rawTitle: String = snippet["title"] as? String, 24 | let rawChannelTitle: String = snippet["channelTitle"] as? String, 25 | let publishedAt: String = snippet["publishedAt"] as? String { 26 | let title: String = decodeHTMLEntities(rawTitle) 27 | let channelTitle: String = decodeHTMLEntities(rawChannelTitle) 28 | let elapsedTime: String = parseElapsedTime(from: publishedAt) ?? "Unknown time" 29 | 30 | // Create a result item with video and channel information 31 | let parsedSnippetItem: [String: String] = [ 32 | "videoId": videoId, 33 | "title": title, 34 | "channelTitle": channelTitle, 35 | "elapsedTime": elapsedTime, 36 | ] 37 | parsedSnippetItems.append(parsedSnippetItem) 38 | } 39 | } 40 | 41 | return parsedSnippetItems 42 | } 43 | 44 | /// Parses the JSON response from the YouTube API containing video statistics and extracts view 45 | /// counts. 46 | /// 47 | /// - Parameter json: A dictionary representing the JSON response from the YouTube API containing 48 | /// video statistics. 49 | /// - Returns: A dictionary with video IDs as keys and view counts as values. 50 | func parseVideoStatisticsJSON(_ json: [String: Any]) -> [String: Int] { 51 | guard let items: [[String: Any]] = json["items"] as? [[String: Any]] else { return [:] } 52 | 53 | var viewCounts: [String: Int] = [:] 54 | 55 | for item: [String: Any] in items { 56 | if let id: String = item["id"] as? String, 57 | let statistics: [String: Any] = item["statistics"] as? [String: Any], 58 | let viewCount = Int(statistics["viewCount"] as? String ?? "0") { 59 | viewCounts[id] = viewCount 60 | } 61 | } 62 | 63 | return viewCounts 64 | } 65 | 66 | // MARK: - Alfred Feedback Generation 67 | 68 | /// Combines the results of `parseVideoSnippetJSON` and `parseVideoStatisticsJSON` into a 69 | /// dictionary formatted according to Alfred's feedback format. 70 | /// 71 | /// - Parameters: 72 | /// - items: An array of dictionaries in the format returned by `parseVideoSnippetJSON`. 73 | /// - viewCounts: A dictionary with video IDs as keys and view counts as values returned by 74 | /// `parseVideoStatisticsJSON`. 75 | /// - Returns: A dictionary containing an `items` key with an array of dictionaries in the format 76 | /// expected by Alfred. 77 | func createAlfredVideoItems( 78 | from items: [[String: Any]], 79 | with viewCounts: [String: Int] 80 | ) -> [String: [[String: Any]]] { 81 | var alfredItems: [[String: Any]] = [] 82 | 83 | for item: [String: Any] in items { 84 | guard let videoId: String = item["videoId"] as? String, 85 | let channelTitle: String = item["channelTitle"] as? String, 86 | let elapsedTime: String = item["elapsedTime"] as? String, 87 | let title: String = item["title"] as? String 88 | else { 89 | continue 90 | } 91 | 92 | let arg = "https://www.youtube.com/watch?v=\(videoId)" 93 | let rawViewCount: Int = viewCounts[videoId] ?? 0 94 | let viewCount: String = formatCount(rawViewCount) 95 | let subtitle = "\(channelTitle) • \(viewCount) views • \(elapsedTime)" 96 | 97 | let alfredItem: [String: Any] = [ 98 | "videoId": videoId, 99 | "title": title, 100 | "subtitle": subtitle, 101 | "arg": arg, 102 | ] 103 | alfredItems.append(alfredItem) 104 | } 105 | 106 | return ["items": alfredItems] 107 | } 108 | 109 | // MARK: - Response Handling 110 | 111 | /// Handles the response from the YouTube API's search endpoint, sends a request to the videos 112 | /// endpoint, and prints the resulting Alfred feedback. 113 | /// 114 | /// - Parameter apiKey: The YouTube API key used to authenticate the request. 115 | /// - Returns: A closure that takes `Data?`, `URLResponse?`, and `Error?` as arguments. 116 | func handleVideoResponse(apiKey: String) -> (Data?, URLResponse?, Error?) -> Void { 117 | { data, _, error in 118 | // Check for network errors. 119 | guard let data: Data = data, error == nil else { 120 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error.")", stderr) 121 | exit(1) 122 | } 123 | 124 | // Parse the JSON object into a dictionary, or display an error message if unsuccessful. 125 | guard let json: [String: Any] = try? JSONSerialization.jsonObject(with: data) as? [String: Any] 126 | else { 127 | fputs(".\nError: Unable to parse JSON.", stderr) 128 | exit(1) 129 | } 130 | 131 | // Check if there's an error in the API response. 132 | if let apiError: [String: Any] = json["error"] as? [String: Any] { 133 | handleAPIError(apiError) 134 | } else { 135 | let items: [[String: Any]] = parseVideoSnippetJSON(json) 136 | let videoIds: String = items.compactMap { $0["videoId"] as? String }.joined(separator: ",") 137 | 138 | let endpoint = "https://www.googleapis.com/youtube/v3/videos" 139 | let queryParams: [String: String] = ["part": "statistics", "id": videoIds, "key": apiKey] 140 | 141 | guard let url: URL = buildURL(with: endpoint, using: queryParams) else { 142 | fputs("Error: Unable to build URL.", stderr) 143 | exit(1) 144 | } 145 | 146 | URLSession.shared.dataTask(with: url) { data, _, error in 147 | guard let data: Data = data, error == nil else { 148 | fputs(".\nError: \(error?.localizedDescription ?? "Unknown error")", stderr) 149 | exit(1) 150 | } 151 | 152 | guard let json: [String: Any] = try? JSONSerialization 153 | .jsonObject(with: data) as? [String: Any] else { 154 | fputs(".\nError: Unable to parse JSON.", stderr) 155 | exit(1) 156 | } 157 | 158 | let viewCounts: [String: Int] = parseVideoStatisticsJSON(json) 159 | 160 | let alfredItems: [String: [[String: Any]]] = createAlfredVideoItems( 161 | from: items, 162 | with: viewCounts 163 | ) 164 | 165 | do { 166 | let alfredFeedback: Data = try serializeJSON(alfredItems) 167 | print(String(data: alfredFeedback, encoding: .utf8)!) 168 | exit(0) 169 | } catch { 170 | fputs(".\nError: Unable to serialize JSON.", stderr) 171 | exit(1) 172 | } 173 | }.resume() 174 | } 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /ytsearch/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | /// Main entry point of the CLI that sends a request to the YouTube API. 4 | func main() { 5 | // Check if the command line arguments have the required count. 6 | guard CommandLine.arguments.count == 3 else { 7 | fputs("usage: ytsearch