├── .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 | 
412 |
413 | Search channels via the `ytc` keyword:
414 |
415 | 
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 | 
422 |
423 | Search live broadcasts via the `ytl` keyword:
424 |
425 | 
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