From 9ee42c1cfd26028c19acabd76e204c5e2e76e52b Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Tue, 13 Jan 2026 23:46:10 +0100 Subject: [PATCH 1/3] Add recording picker, improve recording selection logic --- CCCApi/Sources/CCCApi/MediaCCCApiClient.swift | 18 +---- CCCApi/Sources/CCCApi/Models/Recording.swift | 2 +- .../Features/Talk/RecordingChooser.swift | 56 +++++++++++++++ HackerTube/Features/Talk/TalkView.swift | 68 ++++++++++++++++--- HackerTube/Features/Talk/TalkViewModel.swift | 30 ++++++-- HackerTube/Localizable.xcstrings | 36 +++++++++- 6 files changed, 175 insertions(+), 35 deletions(-) create mode 100644 HackerTube/Features/Talk/RecordingChooser.swift diff --git a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift index 244dc25..4f181ba 100644 --- a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift +++ b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift @@ -77,22 +77,6 @@ public final class MediaCCCApiClient { let (data, _) = try await session.data( from: baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)) let response = try decoder.decode(TalkExtended.self, from: data) - guard let recordings = response.recordings else { - return [] - } - return - recordings - // Remove formats Apple doesn't support - .filter { !$0.mimeType.contains("opus") } - .filter { !$0.mimeType.contains("webm") } - .filter { !$0.mimeType.starts(with: "application") } - // Put the HD versions first - .sorted(by: { lhs, rhs in - lhs.isHighQuality && !rhs.isHighQuality - }) - // Put the audio versions last - .sorted(by: { lhs, rhs in - !lhs.isAudio && rhs.isAudio - }) + return response.recordings ?? [] } } diff --git a/CCCApi/Sources/CCCApi/Models/Recording.swift b/CCCApi/Sources/CCCApi/Models/Recording.swift index a196efa..3eb8748 100644 --- a/CCCApi/Sources/CCCApi/Models/Recording.swift +++ b/CCCApi/Sources/CCCApi/Models/Recording.swift @@ -9,7 +9,7 @@ import Foundation /// A recording is a file that belongs to a talk (event). /// These can be video or audio recordings of the talk in different formats and languages (live-translation), subtitle tracks as srt or slides as pdf. -public struct Recording: Decodable, Identifiable, Equatable, Sendable { +public struct Recording: Decodable, Identifiable, Hashable, Sendable { /// approximate file size in megabytes public let size: Int? /// duration in seconds diff --git a/HackerTube/Features/Talk/RecordingChooser.swift b/HackerTube/Features/Talk/RecordingChooser.swift new file mode 100644 index 0000000..8d52088 --- /dev/null +++ b/HackerTube/Features/Talk/RecordingChooser.swift @@ -0,0 +1,56 @@ +// +// RecordingChooser.swift +// HackerTube +// +// Created by Mathijs Bernson on 13/01/2026. +// + +import CCCApi +import Foundation + +struct RecordingChooser { + func canPlay(recording: Recording) -> Bool { + // AVPlayer cannot play Webm video + if recording.mimeType.contains("webm") { + return false + } + // Ignore any files that are attachments (talk slides) etc. + if recording.mimeType.starts(with: "application") { + return false + } + // Subtitle file, this cannot be played back + if recording.mimeType.starts(with: "text") { + return false + } + + return true + } + + func choosePreferredRecording( + from recordings: [Recording], + prefersHighQuality: Bool, + prefersAudio: Bool + ) -> Recording? { + // TODO: Sort according to the user's preferred language(s) + return recordings + // Remove everything that's not playable + .filter { canPlay(recording: $0) } + // Put the audio versions first, if desired + .sorted(by: { lhs, rhs in + if prefersAudio { + return lhs.isAudio && !rhs.isAudio + } else { + return !lhs.isAudio && rhs.isAudio + } + }) + // Put the HD versions first, if desired + .sorted(by: { lhs, rhs in + if prefersHighQuality { + return lhs.isHighQuality && !rhs.isHighQuality + } else { + return !lhs.isHighQuality && rhs.isHighQuality + } + }) + .first + } +} diff --git a/HackerTube/Features/Talk/TalkView.swift b/HackerTube/Features/Talk/TalkView.swift index 75df53a..28f628d 100644 --- a/HackerTube/Features/Talk/TalkView.swift +++ b/HackerTube/Features/Talk/TalkView.swift @@ -86,19 +86,21 @@ private struct TVPlayerView: View { private struct TalkMainView: View { let talk: Talk - var viewModel: TalkViewModel + @Bindable var viewModel: TalkViewModel var body: some View { VStack(alignment: .leading, spacing: 20) { Group { #if os(tvOS) || os(visionOS) - TVPlayerView(talk: talk, recording: viewModel.preferredRecording) + TVPlayerView(talk: talk, recording: viewModel.selectedRecording) #else Group { - if let preferredRecording = viewModel.preferredRecording { + if let selectedRecording = viewModel.selectedRecording { TalkPlayerView( - talk: talk, recording: preferredRecording, - automaticallyStartsPlayback: true) + talk: talk, + recording: selectedRecording, + automaticallyStartsPlayback: true + ) } else { Rectangle() .fill(.black) @@ -117,9 +119,13 @@ private struct TalkMainView: View { .padding(.horizontal) } - CopyrightView(talk: talk, viewModel: viewModel) - .padding(.horizontal) + RecordingSelectionView( + recordings: viewModel.recordings, + selectedRecording: $viewModel.selectedRecording + ) + .padding(.horizontal) } + .padding(.bottom) .animation(.default, value: viewModel.copyright) #if os(tvOS) .focusSection() @@ -138,7 +144,7 @@ private struct TalkMainView: View { private struct CopyrightView: View { let talk: Talk - var viewModel: TalkViewModel + @Bindable var viewModel: TalkViewModel var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -246,7 +252,7 @@ private struct TalkDescriptionSheetView: View { private struct TalkMetaView: View { let talk: Talk @Binding var selectedRecording: Recording? - var viewModel: TalkViewModel + @Bindable var viewModel: TalkViewModel var body: some View { VStack(alignment: .leading, spacing: 20) { @@ -269,6 +275,50 @@ private struct TalkMetaView: View { if !talk.persons.isEmpty { Label(talk.persons.joined(separator: ", "), systemImage: "person") } + + CopyrightView(talk: talk, viewModel: viewModel) + } + } +} + +private struct RecordingSelectionView: View { + let recordings: [Recording] + @Binding var selectedRecording: Recording? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("Recording") + .font(.headline) + + Picker(selection: $selectedRecording) { + ForEach(recordings) { recording in + Label(recording.description, systemImage: recording.systemImageName) + .tag(recording) + } + } label: { + Text("Select recording") + } + .pickerStyle(.menu) + } + } +} + +extension Recording { + var description: String { + if let width, let height, width > 0 && height > 0 { + return "\(language) \(folder) (\(width)x\(height))" + } else { + return "\(language) \(folder)" + } + } + + var systemImageName: String { + if mimeType.starts(with: "video") { + return "film" + } else if mimeType.starts(with: "audio") { + return "speaker.wave.3" + } else { + return "questionmark" } } } diff --git a/HackerTube/Features/Talk/TalkViewModel.swift b/HackerTube/Features/Talk/TalkViewModel.swift index e2fff71..6ec9251 100644 --- a/HackerTube/Features/Talk/TalkViewModel.swift +++ b/HackerTube/Features/Talk/TalkViewModel.swift @@ -19,25 +19,41 @@ enum CopyrightState: Equatable { class TalkViewModel { var currentTalk: Talk? var recordings: [Recording] = [] - var preferredRecording: Recording? + var selectedRecording: Recording? var copyright: CopyrightState = .loading private let client: MediaCCCApiClient private let mediaAnalyzer: MediaAnalyzer + private let recordingChooser: RecordingChooser init() { client = .init() mediaAnalyzer = .init() + recordingChooser = .init() } func loadRecordings(for talk: Talk) async throws { - let recordings = try await client.recordings(for: talk) + // Populate the recordings dropdown + recordings = try await client.recordings(for: talk) + // Remove everything that's not playable + .filter { recordingChooser.canPlay(recording: $0) } + // Put the HD versions first + .sorted(by: { lhs, rhs in + return lhs.isHighQuality && !rhs.isHighQuality + }) + // Put the audio versions last + .sorted(by: { lhs, rhs in + return !lhs.isAudio && rhs.isAudio + }) + + // Pre-select the preferred recording + selectedRecording = recordingChooser.choosePreferredRecording( + from: recordings, + prefersHighQuality: true, // TODO: Hook up with a preference and/or respect low data mode + prefersAudio: false // TODO: Hook up with a preference 'prefer audio' + ) + currentTalk = talk - self.recordings = recordings - let hdRecording = recordings.first(where: { $0.isHighQuality && $0.isVideo }) - let sdRecording = recordings.first(where: { !$0.isHighQuality && $0.isVideo }) - let audioRecording = recordings.first(where: { $0.isAudio }) - preferredRecording = hdRecording ?? sdRecording ?? audioRecording await loadCopyright(for: recordings) } diff --git a/HackerTube/Localizable.xcstrings b/HackerTube/Localizable.xcstrings index 15922d1..e2eee33 100644 --- a/HackerTube/Localizable.xcstrings +++ b/HackerTube/Localizable.xcstrings @@ -251,6 +251,23 @@ } } }, + "Recording" : { + "comment" : "A label displayed above a picker for selecting a recording, meaning the file to be played back.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Opname" + } + } + } + }, "Search" : { "comment" : "Heading for the search view and tab.", "localizations" : { @@ -285,6 +302,23 @@ } } }, + "Select recording" : { + "comment" : "A label for a picker that lets a user select a recording, meaning the file to be played back.", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aufnahme auswählen" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kies opname" + } + } + } + }, "Year" : { "comment" : "A label that indicates the year to filter talks by.", "isCommentAutoGenerated" : true, @@ -304,5 +338,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file From 136d5a739d7101564c735ba503b20ed2b31d2f02 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 2 Mar 2026 17:17:42 +0100 Subject: [PATCH 2/3] Add logging to API client --- CCCApi/Sources/CCCApi/MediaCCCApiClient.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift index 4f181ba..a9629e0 100644 --- a/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift +++ b/CCCApi/Sources/CCCApi/MediaCCCApiClient.swift @@ -6,11 +6,13 @@ // import Foundation +import os.log public final class MediaCCCApiClient { private let session: URLSession private let baseURL = URL(string: "https://api.media.ccc.de/public")! private let decoder = JSONDecoder() + private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "CCCApi") public init(urlSession: URLSession = .shared) { session = urlSession @@ -20,35 +22,42 @@ public final class MediaCCCApiClient { // MARK: Conferences public func conferences() async throws -> [Conference] { - let (data, _) = try await session.data(from: baseURL.appendingPathComponent("conferences")) + let url = baseURL.appendingPathComponent("conferences") + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) let response = try decoder.decode(ConferencesResponse.self, from: data) return response.conferences } public func conference(acronym: String) async throws -> Conference { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym)) + let url = baseURL.appendingPathComponent("conferences").appendingPathComponent(acronym) + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) return try decoder.decode(Conference.self, from: data) } // MARK: Talks public func talk(id: String) async throws -> Talk { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent(id)) + let url = baseURL.appendingPathComponent("events").appendingPathComponent(id) + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) let response = try decoder.decode(Talk.self, from: data) return response } public func talks() async throws -> [Talk] { - let (data, _) = try await session.data(from: baseURL.appendingPathComponent("events")) + let url = baseURL.appendingPathComponent("events") + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) let response = try decoder.decode(EventsResponse.self, from: data) return response.events } public func recentTalks() async throws -> [Talk] { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent("recent")) + let url = baseURL.appendingPathComponent("events").appendingPathComponent("recent") + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) let response = try decoder.decode(EventsResponse.self, from: data) return response.events } @@ -57,6 +66,7 @@ public final class MediaCCCApiClient { let url = baseURL.appendingPathComponent("events").appendingPathComponent("popular") var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! components.queryItems = [URLQueryItem(name: "year", value: String(year))] + logger.debug("GET \(components.url!)") let (data, _) = try await session.data(from: components.url!) let response = try decoder.decode(EventsResponse.self, from: data) return response.events @@ -66,6 +76,7 @@ public final class MediaCCCApiClient { let url = baseURL.appendingPathComponent("events").appendingPathComponent("search") var components = URLComponents(url: url, resolvingAgainstBaseURL: true)! components.queryItems = [URLQueryItem(name: "q", value: query)] + logger.debug("GET \(components.url!)") let (data, _) = try await session.data(from: components.url!) let response = try decoder.decode(EventsResponse.self, from: data) return response.events @@ -74,8 +85,9 @@ public final class MediaCCCApiClient { // MARK: Recordings public func recordings(for talk: Talk) async throws -> [Recording] { - let (data, _) = try await session.data( - from: baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid)) + let url = baseURL.appendingPathComponent("events").appendingPathComponent(talk.guid) + logger.debug("GET \(url)") + let (data, _) = try await session.data(from: url) let response = try decoder.decode(TalkExtended.self, from: data) return response.recordings ?? [] } From 23df214127949ee0e056aa638e2b3e3bba03c401 Mon Sep 17 00:00:00 2001 From: Mathijs Bernson Date: Mon, 2 Mar 2026 18:09:28 +0100 Subject: [PATCH 3/3] Improve recording chooser logic and add tests --- .../Features/Talk/RecordingChooser.swift | 49 ++- HackerTubeTests/RecordingChooserTests.swift | 288 ++++++++++++++++++ ...n-enshittification-resistant-internet.json | 262 ++++++++++++++++ ...ets-gone-fraud-at-an-industrial-scale.json | 263 ++++++++++++++++ ...p-flop-bis-zum-eigenen-betriebssystem.json | 244 +++++++++++++++ HackerTubeTests/URLParserTests.swift | 40 ++- 6 files changed, 1121 insertions(+), 25 deletions(-) create mode 100644 HackerTubeTests/RecordingChooserTests.swift create mode 100644 HackerTubeTests/TestData/event-39c3-a-post-american-enshittification-resistant-internet.json create mode 100644 HackerTubeTests/TestData/event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json create mode 100644 HackerTubeTests/TestData/event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json diff --git a/HackerTube/Features/Talk/RecordingChooser.swift b/HackerTube/Features/Talk/RecordingChooser.swift index 8d52088..7e212fe 100644 --- a/HackerTube/Features/Talk/RecordingChooser.swift +++ b/HackerTube/Features/Talk/RecordingChooser.swift @@ -5,36 +5,49 @@ // Created by Mathijs Bernson on 13/01/2026. // +import AVFoundation import CCCApi import Foundation struct RecordingChooser { - func canPlay(recording: Recording) -> Bool { - // AVPlayer cannot play Webm video - if recording.mimeType.contains("webm") { - return false + let preferredLanguages: [Locale] + + init(preferredLanguages: [Locale] = { + if #available(iOS 26, tvOS 26, macOS 26, *) { + return Locale.preferredLocales + } else { + return Locale.preferredLanguages.map(Locale.init(identifier:)) } + }()) { + self.preferredLanguages = preferredLanguages + } + + /// Checks whether the app on the current OS can play back the given recording. + func canPlay(recording: Recording) -> Bool { + let mimeType = recording.mimeType + // Ignore any files that are attachments (talk slides) etc. - if recording.mimeType.starts(with: "application") { + if mimeType.starts(with: "application") { return false } + // Subtitle file, this cannot be played back - if recording.mimeType.starts(with: "text") { + if mimeType.starts(with: "text") { return false } - return true + return AVURLAsset.isPlayableExtendedMIMEType(mimeType) } + /// Selects the best recording to be played, based on the parameters and user preferences. func choosePreferredRecording( from recordings: [Recording], prefersHighQuality: Bool, prefersAudio: Bool ) -> Recording? { - // TODO: Sort according to the user's preferred language(s) return recordings // Remove everything that's not playable - .filter { canPlay(recording: $0) } + .filter(canPlay) // Put the audio versions first, if desired .sorted(by: { lhs, rhs in if prefersAudio { @@ -43,6 +56,13 @@ struct RecordingChooser { return !lhs.isAudio && rhs.isAudio } }) + // Sort by language preference + .sorted { lhs, rhs in + let l = languageScore(for: lhs) + let r = languageScore(for: rhs) + if l.0 != r.0 { return l.0 > r.0 } + return l.1 < r.1 + } // Put the HD versions first, if desired .sorted(by: { lhs, rhs in if prefersHighQuality { @@ -53,4 +73,15 @@ struct RecordingChooser { }) .first } + + private func languageScore(for recording: Recording) -> (Int, Int) { + let preferredCodes = preferredLanguages.compactMap { $0.language.languageCode } + // Normalize ISO 639-2 codes (e.g. "eng") to BCP-47 (e.g. "en") via Locale + let recordingCodes = recording.language + .split(separator: "-") + .compactMap { Locale(identifier: String($0)).language.languageCode } + let matchCount = recordingCodes.filter { preferredCodes.contains($0) }.count + let firstMatchIndex = preferredCodes.firstIndex { recordingCodes.contains($0) } ?? Int.max + return (matchCount, firstMatchIndex) + } } diff --git a/HackerTubeTests/RecordingChooserTests.swift b/HackerTubeTests/RecordingChooserTests.swift new file mode 100644 index 0000000..99c9ea9 --- /dev/null +++ b/HackerTubeTests/RecordingChooserTests.swift @@ -0,0 +1,288 @@ +// +// RecordingChooserTests.swift +// HackerTubeTests +// + +import Foundation +import Testing +@testable import HackerTube +@testable import CCCApi + +@Suite("RecordingChooser") +struct RecordingChooserTests { + + @Suite("canPlay") + struct CanPlay { + let chooser = RecordingChooser() + + @Test("Rejects unplayable MIME types", arguments: [ + "text/vtt", + "application/pdf", + ]) + func rejectsUnplayable(_ mimeType: String) { + #expect(chooser.canPlay(recording: .make(mimeType: mimeType)) == false) + } + + @Test("Accepts playable MIME types", arguments: [ + "video/mp4", + "video/mp4;codecs=av01", + "audio/mpeg", + ]) + func acceptsPlayable(_ mimeType: String) { + #expect(chooser.canPlay(recording: .make(mimeType: mimeType))) + } + } + + @Suite("choosePreferredRecording") + struct ChoosePreferred { + let chooser = RecordingChooser() + + @Test func returnsNilForEmptyList() { + #expect( + chooser.choosePreferredRecording(from: [], prefersHighQuality: true, prefersAudio: false) == nil + ) + } + + @Test func returnsNilWhenAllUnplayable() { + let recordings = [ + Recording.make(mimeType: "video/webm"), + Recording.make(mimeType: "text/vtt"), + Recording.make(mimeType: "application/pdf"), + ] + #expect( + chooser.choosePreferredRecording(from: recordings, prefersHighQuality: true, prefersAudio: false) == nil + ) + } + + // [Recording].example contains: audio/mpeg (LQ), video/mp4 (SD/LQ), video/mp4 (HD/HQ) + + @Test func prefersHDVideoByDefault() { + let result = chooser.choosePreferredRecording( + from: .example, + prefersHighQuality: true, + prefersAudio: false + ) + #expect(result?.isHighQuality == true) + #expect(result?.isVideo == true) + } + + @Test func prefersSDWhenLowQualityRequested() { + let result = chooser.choosePreferredRecording( + from: .example, + prefersHighQuality: false, + prefersAudio: false + ) + #expect(result?.isHighQuality == false) + #expect(result?.isVideo == true) + } + + @Test func prefersAudioOverVideoWhenRequested() { + // Both same quality — audio preference is the deciding factor + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: false), + Recording.make(mimeType: "audio/mpeg", isHighQuality: false), + ] + let result = chooser.choosePreferredRecording( + from: recordings, + prefersHighQuality: false, + prefersAudio: true + ) + #expect(result?.isAudio == true) + } + + @Test func prefersHDAudioWhenBothPreferred() { + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: true), + Recording.make(mimeType: "video/mp4", isHighQuality: false), + Recording.make(mimeType: "audio/mpeg", isHighQuality: false), + Recording.make(mimeType: "audio/mpeg", isHighQuality: true), + ] + let result = chooser.choosePreferredRecording( + from: recordings, + prefersHighQuality: true, + prefersAudio: true + ) + #expect(result?.isAudio == true) + #expect(result?.isHighQuality == true) + } + + @Test func filtersWebmEvenWhenFirstInList() { + let recordings = [ + Recording.make(mimeType: "video/webm", isHighQuality: true), + Recording.make(mimeType: "video/webm;codecs=av01", isHighQuality: true), + Recording.make(mimeType: "video/mp4", isHighQuality: false), + ] + let result = chooser.choosePreferredRecording( + from: recordings, + prefersHighQuality: true, + prefersAudio: false + ) + #expect(result?.mimeType == "video/mp4") + } + + @Suite("realWorldData") + struct RealWorldData { + private func loadRecordings( + fromJSON filename: String, + file: StaticString = #filePath + ) throws -> [Recording] { + let url = URL(filePath: "\(file)") + .deletingLastPathComponent() + .appending(path: "TestData/\(filename)") + let data = try Data(contentsOf: url) + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .formatted(CustomISO8601DateFormatter()) + struct TalkJSON: Decodable { let recordings: [Recording] } + return try decoder.decode(TalkJSON.self, from: data).recordings + } + + @Test func englishSpeakerGetsEnglishRecordingFromDoctorowTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "en")]) + let recordings = try loadRecordings(fromJSON: "event-39c3-a-post-american-enshittification-resistant-internet.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + #expect(result?.language.contains("eng") == true) + #expect(result?.isHighQuality == true) + } + + @Test func germanSpeakerGetsGermanRecordingFromDeutschlandticketsTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "de")]) + let recordings = try loadRecordings(fromJSON: "event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + #expect(result?.language.contains("deu") == true) + #expect(result?.isHighQuality == true) + } + + @Test func germanEnglishSpeakerPrefersMultilingualRecordingFromDeutschlandticketsTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [ + Locale(identifier: "de"), Locale(identifier: "en"), + ]) + let recordings = try loadRecordings(fromJSON: "event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + // eng-deu-fra has 2 matching languages (de + en), beating deu-only (1) or eng-only (1) + #expect(result?.language == "eng-deu-fra") + #expect(result?.isHighQuality == true) + } + + @Test func germanSpeakerGetsGermanRecordingFromFactorioTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "de")]) + let recordings = try loadRecordings(fromJSON: "event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + #expect(result?.language.contains("deu") == true) + #expect(result?.isHighQuality == true) + } + + @Test func englishSpeakerGetsEnglishRecordingFromFactorioTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "en")]) + let recordings = try loadRecordings(fromJSON: "event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + #expect(result?.language.contains("eng") == true) + #expect(result?.isHighQuality == true) + } + + @Test func germanEnglishSpeakerPrefersBilingualRecordingFromFactorioTalk() throws { + let chooser = RecordingChooser(preferredLanguages: [ + Locale(identifier: "de"), Locale(identifier: "en"), + ]) + let recordings = try loadRecordings(fromJSON: "event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json") + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + // deu-eng has 2 matching languages (de + en), beating deu-only (1) or eng-only (1) + #expect(result?.language == "deu-eng") + #expect(result?.isHighQuality == true) + } + } + + @Suite("languagePreference") + struct LanguagePreference { + @Test func prefersRecordingInPreferredLanguage() { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "en")]) + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "fra"), + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "eng"), + ] + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: false, prefersAudio: false + ) + #expect(result?.language == "eng") + } + + @Test func prefersMixedTrackContainingPreferredLanguage() { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "en")]) + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "fra"), + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "deu-eng"), + ] + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: false, prefersAudio: false + ) + #expect(result?.language == "deu-eng") + } + + @Test func prefersRecordingWithMoreMatchingLanguages() { + let chooser = RecordingChooser(preferredLanguages: [ + Locale(identifier: "de"), Locale(identifier: "en"), + ]) + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "eng"), + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "deu-eng"), + ] + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: false, prefersAudio: false + ) + #expect(result?.language == "deu-eng") + } + + @Test func fallsBackToQualityWhenNoLanguageMatch() { + let chooser = RecordingChooser(preferredLanguages: [Locale(identifier: "en")]) + let recordings = [ + Recording.make(mimeType: "video/mp4", isHighQuality: false, language: "fra"), + Recording.make(mimeType: "video/mp4", isHighQuality: true, language: "fra"), + ] + let result = chooser.choosePreferredRecording( + from: recordings, prefersHighQuality: true, prefersAudio: false + ) + #expect(result?.isHighQuality == true) + } + } + } +} + +// MARK: - Test Helpers + +private extension Recording { + static func make( + mimeType: String, + isHighQuality: Bool = false, + language: String = "eng", + filename: String = UUID().uuidString + ) -> Recording { + Recording( + size: nil, + length: nil, + mimeType: mimeType, + language: language, + filename: filename, + state: "new", + folder: "", + isHighQuality: isHighQuality, + width: nil, + height: nil, + updatedAt: Date(), + recordingURL: URL(string: "https://example.com/r")!, + url: URL(string: "https://example.com/")!, + eventURL: URL(string: "https://example.com/e")!, + conferenceURL: URL(string: "https://example.com/c")! + ) + } +} diff --git a/HackerTubeTests/TestData/event-39c3-a-post-american-enshittification-resistant-internet.json b/HackerTubeTests/TestData/event-39c3-a-post-american-enshittification-resistant-internet.json new file mode 100644 index 0000000..5e3ebb7 --- /dev/null +++ b/HackerTubeTests/TestData/event-39c3-a-post-american-enshittification-resistant-internet.json @@ -0,0 +1,262 @@ +{ + "guid": "c9f5a6df-6c79-5492-b3e0-110347358445", + "title": "A post-American, enshittification-resistant internet", + "subtitle": null, + "slug": "39c3-a-post-american-enshittification-resistant-internet", + "link": "https://events.ccc.de/congress/2025/hub/event/detail/a-post-american-enshittification-resistant-internet", + "description": "Trump has staged an unscheduled, midair rapid disassembly of the global system of trade. Ironically, it is this system that prevented all of America's trading partners from disenshittifying their internet: the US trade representative threatened the world with tariffs unless they passed laws that criminalized reverse-engineering and modding. By banning \"adversarial interoperability,\" America handcuffed the world's technologists, banning them from creating the mods, hacks, alt clients, scrapers, and other tools needed to liberate their neighbours from the enshittificatory predations of the ketamine-addled zuckermuskian tyrants of US Big Tech.\n\nWell, when life gives you SARS, you make sarsaparilla. The Trump tariffs are here, and it's time to pick the locks on the those handcuffs and set the world's hackers loose on Big Tech. Happy Liberation Day, everyone!\n\nEnshittification wasn't an accident. It also wasn't inevitable. This isn't the iron laws of economics at work, nor is it the great forces of history.\n\nEnshittification was a choice: named individuals, in living memory, enacted policies that created the enshittogenic environment. They created a world that encouraged tech companies to merge to monopoly, transforming the internet into \"five giant websites, each filled with screenshots of the other four.\" They let these monopolists rip us off and spy on us.\n\nAnd they banned us from fighting back, claiming that anyone who modified a technology without permission from its maker was a pirate (or worse, a terrorist). They created a system of \"felony contempt of business-model,\" where it's literally a crime to change how your own devices work. They declared war on the general-purpose computer and demanded a computer that would do what the manufacturer told it to do (even if the owner of the computer didn't want that).\n\nWe are at a turning point in the decades-long war on general-purpose computing. Geopolitics are up for grabs. The future is ours to seize.\n\nIn my 24 years with EFF, I have seen many strange moments, but never one quite like this. There's plenty of terrifying things going on right now, but there's also a massive, amazing, incredibly opportunity to seize the means of computation.\n\nLet's take it. '\n\nLicensed to the public under http://creativecommons.org/licenses/by/4.0", + "original_language": "eng", + "persons": [ + "Cory Doctorow" + ], + "tags": [ + "1421", + "2025", + "39c3", + "Ethics, Society \u0026 Politics", + "One", + "39c3-eng", + "39c3-deu", + "39c3-fra", + "Day 2" + ], + "view_count": 181788, + "promoted": true, + "date": "2025-12-28T13:30:00.000+01:00", + "release_date": "2025-12-28T00:00:00.000+01:00", + "updated_at": "2026-03-02T15:15:07.314+01:00", + "length": 3672, + "duration": 3672, + "thumb_url": "https://static.media.ccc.de/media/congress/2025/1421-c9f5a6df-6c79-5492-b3e0-110347358445.jpg", + "poster_url": "https://static.media.ccc.de/media/congress/2025/1421-c9f5a6df-6c79-5492-b3e0-110347358445_preview.jpg", + "timeline_url": "https://static.media.ccc.de/media/congress/2025/1421-c9f5a6df-6c79-5492-b3e0-110347358445.timeline.jpg", + "thumbnails_url": "https://static.media.ccc.de/media/congress/2025/1421-c9f5a6df-6c79-5492-b3e0-110347358445.thumbnails.vtt", + "frontend_link": "https://media.ccc.de/v/39c3-a-post-american-enshittification-resistant-internet", + "url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_title": "39C3: Power Cycles", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3", + "related": [], + "recordings": [ + { + "size": 880, + "length": 3672, + "mime_type": "video/webm;codecs=av01", + "language": "eng-deu-fra", + "filename": "39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_av1-hd.webm", + "state": "new", + "folder": "av1-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T19:41:02.634+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/av1-hd/39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_av1-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/94399", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": null, + "length": null, + "mime_type": "text/vtt", + "language": "eng", + "filename": "1421-c9f5a6df-6c79-5492-b3e0-110347358445-eng.vtt", + "state": "auto", + "folder": "", + "high_quality": true, + "width": null, + "height": null, + "updated_at": "2025-12-30T18:03:26.369+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/1421-c9f5a6df-6c79-5492-b3e0-110347358445-eng.vtt", + "url": "https://api.media.ccc.de/public/recordings/95841", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 1039, + "length": 3672, + "mime_type": "video/webm", + "language": "eng-deu-fra", + "filename": "39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_webm-hd.webm", + "state": "new", + "folder": "webm-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T20:31:38.369+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-hd/39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_webm-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/94431", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 280, + "length": 3672, + "mime_type": "video/webm", + "language": "eng-deu-fra", + "filename": "39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_webm-sd.webm", + "state": "new", + "folder": "webm-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-28T20:30:46.685+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-sd/39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_webm-sd.webm", + "url": "https://api.media.ccc.de/public/recordings/94430", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 38, + "length": 3672, + "mime_type": "audio/opus", + "language": "deu", + "filename": "39c3-1421-deu-A_post-American_enshittification-resistant_internet_opus-2.opus", + "state": "new", + "folder": "opus-translation", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:50:05.981+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus-translation/39c3-1421-deu-A_post-American_enshittification-resistant_internet_opus-2.opus", + "url": "https://api.media.ccc.de/public/recordings/94406", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 37, + "length": 3672, + "mime_type": "audio/opus", + "language": "eng", + "filename": "39c3-1421-eng-A_post-American_enshittification-resistant_internet_opus.opus", + "state": "new", + "folder": "opus", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:57:25.823+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus/39c3-1421-eng-A_post-American_enshittification-resistant_internet_opus.opus", + "url": "https://api.media.ccc.de/public/recordings/94405", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 56, + "length": 3672, + "mime_type": "audio/mpeg", + "language": "deu", + "filename": "39c3-1421-deu-A_post-American_enshittification-resistant_internet_mp3-2.mp3", + "state": "new", + "folder": "mp3-translated", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:49:57.451+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3-translated/39c3-1421-deu-A_post-American_enshittification-resistant_internet_mp3-2.mp3", + "url": "https://api.media.ccc.de/public/recordings/94404", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 56, + "length": 3672, + "mime_type": "audio/mpeg", + "language": "eng", + "filename": "39c3-1421-eng-A_post-American_enshittification-resistant_internet_mp3.mp3", + "state": "new", + "folder": "mp3", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:53:24.253+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3/39c3-1421-eng-A_post-American_enshittification-resistant_internet_mp3.mp3", + "url": "https://api.media.ccc.de/public/recordings/94396", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 371, + "length": 3672, + "mime_type": "video/mp4", + "language": "eng-deu-fra", + "filename": "39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_sd.mp4", + "state": "new", + "folder": "h264-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-28T17:41:36.326+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-sd/39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_sd.mp4", + "url": "https://api.media.ccc.de/public/recordings/94311", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 1634, + "length": 3672, + "mime_type": "video/mp4", + "language": "eng-deu-fra", + "filename": "39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_hd.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T17:15:00.184+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1421-eng-deu-fra-A_post-American_enshittification-resistant_internet_hd.mp4", + "url": "https://api.media.ccc.de/public/recordings/94290", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 1114, + "length": 3672, + "mime_type": "video/mp4", + "language": "fra", + "filename": "39c3-1421-fra-A_post-American_enshittification-resistant_internet.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T17:14:27.895+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1421-fra-A_post-American_enshittification-resistant_internet.mp4", + "url": "https://api.media.ccc.de/public/recordings/94289", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 1114, + "length": 3672, + "mime_type": "video/mp4", + "language": "deu", + "filename": "39c3-1421-deu-A_post-American_enshittification-resistant_internet.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T17:13:58.201+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1421-deu-A_post-American_enshittification-resistant_internet.mp4", + "url": "https://api.media.ccc.de/public/recordings/94288", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 1116, + "length": 3672, + "mime_type": "video/mp4", + "language": "eng", + "filename": "39c3-1421-eng-A_post-American_enshittification-resistant_internet.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T17:13:27.777+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1421-eng-A_post-American_enshittification-resistant_internet.mp4", + "url": "https://api.media.ccc.de/public/recordings/94287", + "event_url": "https://api.media.ccc.de/public/events/c9f5a6df-6c79-5492-b3e0-110347358445", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + } + ] +} \ No newline at end of file diff --git a/HackerTubeTests/TestData/event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json b/HackerTubeTests/TestData/event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json new file mode 100644 index 0000000..67df6f9 --- /dev/null +++ b/HackerTubeTests/TestData/event-39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale.json @@ -0,0 +1,263 @@ +{ + "guid": "a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "title": "All my Deutschlandtickets gone: Fraud at an industrial scale", + "subtitle": null, + "slug": "39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale", + "link": "https://events.ccc.de/congress/2025/hub/event/detail/all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale", + "description": "The Deutschlandticket was the flagship transport policy of the last government, rolled out in an impressive timescale for a political project; but this speed came with a cost - a system ripe for fraud at an industrial scale.\n\nGerman public transport is famously decentralised, with thousands of individual companies involved in ticketing and operations. Unifying all of these under one national, secure, system has proven a challenge too far for politicians. The end result: losses in the hundreds of millions of Euros, compensated to the transport companies from state and federal budgets to keep the system afloat, and nobody willing to take responsibility.\n\nThis talk will cover the political, policy, and technical mistakes that lead to this mess; how we can learn from these mistakes; and what we can do to ensure the Deutschlandticket has a viable future.\n\nAt last years Congress Q presented [a deep-dive into the technical details of train ticketing](https://media.ccc.de/v/38c3-what-s-inside-my-train-ticket) and its [Zügli](https://zügli.app) platform for this; since then, things have gone rather out of hand. The little side-project for looking into the details of train tickets turned into a full-time project for detecting ticketing fraud. This talk details an executive summary of the madness that has been the past year, and how we accidentally ended up in national and international politics working to secure the Deutschlandticket.\n\nShortly after last year's talk, we were contacted about some *interesting* looking tickets someone noticed, issued by the Vetter GmbH Omnibus- und Mietwagenbetrieb - or so they claimed to be. These were normal Deutschlandtickets, but with a few weird mistakes in them. At first, we thought nothing much of it; mistakes happen. But, on further investigation, these turned out to not be legitimate tickets at all, but rather from a fraudulent website by the name of d-ticket.su, using the private signing key obtained under suspicious circumstances. How exactly this key came into the wrong hands remains unclear, but we present the possible explanations for how this could've happened, how many responsible have been thoroughly uncooperative in getting to the bottom of this, and how the supporting systems and processes of the Deutschlandticket were unable to cope with this situation.\n\nParallel to this, another fraud has been draining the transport companies of their much-needed cash: SEPA Direct Debit fraud. Often, a direct debit payment can be setup online with little more than an IBAN and ticking a box; and most providers of the Deutschlandticket offer an option to pay via direct debit. Fraudsters have noticed this, and mass purchase Deutschlandtickets with invalid or stolen IBANs before flipping them for a discounted price on Telegram; made easier because most transport companies issue a ticket immediately, before the direct debit has been fully processed. The supporting systems of the Deutschlandticket in many cases don't even provide for the revocation of such tickets. We will detail the hallmarks of this fraud, how transport companies can work to prevent it, and how we tracked down the fraudsters by their own careless mistakes.\n\nLicensed to the public under http://creativecommons.org/licenses/by/4.0", + "original_language": "eng", + "persons": [ + "Q Misell", + "551724 / maya boeckh" + ], + "tags": [ + "1254", + "2025", + "39c3", + "Security", + "One", + "39c3-eng", + "39c3-deu", + "39c3-fra", + "Day 1" + ], + "view_count": 117379, + "promoted": false, + "date": "2025-12-27T16:00:00.000+01:00", + "release_date": "2025-12-27T00:00:00.000+01:00", + "updated_at": "2026-03-02T14:45:07.646+01:00", + "length": 3610, + "duration": 3610, + "thumb_url": "https://static.media.ccc.de/media/congress/2025/1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40.jpg", + "poster_url": "https://static.media.ccc.de/media/congress/2025/1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40_preview.jpg", + "timeline_url": "https://static.media.ccc.de/media/congress/2025/1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40.timeline.jpg", + "thumbnails_url": "https://static.media.ccc.de/media/congress/2025/1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40.thumbnails.vtt", + "frontend_link": "https://media.ccc.de/v/39c3-all-my-deutschlandtickets-gone-fraud-at-an-industrial-scale", + "url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_title": "39C3: Power Cycles", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3", + "related": [], + "recordings": [ + { + "size": 463, + "length": 3610, + "mime_type": "video/webm;codecs=av01", + "language": "eng-deu-fra", + "filename": "39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_av1-hd.webm", + "state": "new", + "folder": "av1-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T19:57:11.685+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/av1-hd/39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_av1-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/93972", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": null, + "length": null, + "mime_type": "text/vtt", + "language": "eng", + "filename": "1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40-eng.vtt", + "state": "auto", + "folder": "", + "high_quality": true, + "width": null, + "height": null, + "updated_at": "2025-12-27T23:39:37.816+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/1254-a2dd3dc7-ecae-50b3-82d9-266ad02f7a40-eng.vtt", + "url": "https://api.media.ccc.de/public/recordings/94012", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 574, + "length": 3610, + "mime_type": "video/webm", + "language": "eng-deu-fra", + "filename": "39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_webm-hd.webm", + "state": "new", + "folder": "webm-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T20:24:10.884+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-hd/39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_webm-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/93979", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 254, + "length": 3610, + "mime_type": "video/webm", + "language": "eng-deu-fra", + "filename": "39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_webm-sd.webm", + "state": "new", + "folder": "webm-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-27T20:20:25.760+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-sd/39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_webm-sd.webm", + "url": "https://api.media.ccc.de/public/recordings/93978", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 258, + "length": 3610, + "mime_type": "video/mp4", + "language": "eng-deu-fra", + "filename": "39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_sd.mp4", + "state": "new", + "folder": "h264-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-27T19:48:05.807+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-sd/39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_sd.mp4", + "url": "https://api.media.ccc.de/public/recordings/93961", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 36, + "length": 3610, + "mime_type": "audio/opus", + "language": "deu", + "filename": "39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_opus-2.opus", + "state": "new", + "folder": "opus-translation", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-27T19:47:57.985+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus-translation/39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_opus-2.opus", + "url": "https://api.media.ccc.de/public/recordings/93960", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 55, + "length": 3610, + "mime_type": "audio/mpeg", + "language": "deu", + "filename": "39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_mp3-2.mp3", + "state": "new", + "folder": "mp3-translated", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-27T19:47:53.801+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3-translated/39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_mp3-2.mp3", + "url": "https://api.media.ccc.de/public/recordings/93959", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 38, + "length": 3610, + "mime_type": "audio/opus", + "language": "eng", + "filename": "39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_opus.opus", + "state": "new", + "folder": "opus", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:57:26.149+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus/39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_opus.opus", + "url": "https://api.media.ccc.de/public/recordings/93958", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 55, + "length": 3610, + "mime_type": "audio/mpeg", + "language": "eng", + "filename": "39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_mp3.mp3", + "state": "new", + "folder": "mp3", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-28T19:53:24.477+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3/39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_mp3.mp3", + "url": "https://api.media.ccc.de/public/recordings/93957", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 806, + "length": 3610, + "mime_type": "video/mp4", + "language": "eng-deu-fra", + "filename": "39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_hd.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T19:46:46.981+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1254-eng-deu-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale_hd.mp4", + "url": "https://api.media.ccc.de/public/recordings/93951", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 580, + "length": 3610, + "mime_type": "video/mp4", + "language": "fra", + "filename": "39c3-1254-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T19:46:31.108+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1254-fra-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "url": "https://api.media.ccc.de/public/recordings/93950", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 580, + "length": 3610, + "mime_type": "video/mp4", + "language": "deu", + "filename": "39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T19:46:17.085+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1254-deu-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "url": "https://api.media.ccc.de/public/recordings/93949", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 581, + "length": 3610, + "mime_type": "video/mp4", + "language": "eng", + "filename": "39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-27T19:46:03.563+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1254-eng-All_my_Deutschlandtickets_gone_Fraud_at_an_industrial_scale.mp4", + "url": "https://api.media.ccc.de/public/recordings/93948", + "event_url": "https://api.media.ccc.de/public/events/a2dd3dc7-ecae-50b3-82d9-266ad02f7a40", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + } + ] +} \ No newline at end of file diff --git a/HackerTubeTests/TestData/event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json b/HackerTubeTests/TestData/event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json new file mode 100644 index 0000000..0beb24d --- /dev/null +++ b/HackerTubeTests/TestData/event-39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem.json @@ -0,0 +1,244 @@ +{ + "guid": "6189eca4-8ac2-5606-af23-628b82eb4a54", + "title": "CPU Entwicklung in Factorio", + "subtitle": "Vom D-Flip-Flop bis zum eigenen Betriebssystem", + "slug": "39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem", + "link": "https://events.ccc.de/congress/2025/hub/event/detail/cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem", + "description": "Factorio ist ein Fabriksimulationsspiel mit integriertem Logiksystem. Dies ermöglichte mir den Bau einer CPU, die unter anderem aus einer 5-stufiger Pipeline, einer Forwarding Logikeinheit, Interrupt Handling sowie einem I/O Interface besteht. Über einen selbst geschriebenen Assembler konnte ich ein eigenes Betriebssystem und Programme wie Minesweeper oder Snake integrieren. \r\nDer Talk soll euch zeigen, wie sich klassische Computerarchitektur in einem völlig anderen technischen Kontext umsetzen lässt und wo dabei überraschend echte Probleme der CPU-Entwicklung auftreten.\r\nKommt mit auf die Reise: Vom Blick auf den gesamten Computer bis hinunter zu den einzelnen Logikgattern ist es nur eine Mausradbewegung entfernt!\r\n\r\nFactorio ist ein Spiel über Fabrikautomation - Förderbänder, Dampfmaschinen und Produktionsketten stehen im Vordergrund. Eigentlich ist das interne Logiksystem („Combinators“) gedacht für die Steuerung der Fabrik, jedoch erlaubt es auch die Entwicklung komplexer Hardware.\r\n\r\nIn diesem Vortrag erzähle ich meine Geschichte, wie ich eine vollständige RISC-V-Architektur in Factorio rein aus Vanilla-Combinators erschaffen habe:\r\nDie CPU arbeitet mit 32 Bit-Wörtern, verfügt über 32 General Purpose Register, 128 KB RAM/Persistent Storage, eine 5-stufige Pipeline mit Forwarding und Hazard-Handling sowie eine Logikeinheit für Branches und Interrupts. Ein Display-Controller steuert eine Konsolen-Ausgabe sowie ein Farbdisplay, während ein Keyboard-Controller Eingaben über physische In-Game-Tasten ermöglicht.\r\n\r\nErgänzt wird die Hardware auf der Softwareseite durch das Betriebssystem *FactOS*, das ein einfaches Filesystem sowie Systemcalls (zum Beispiel zum Drucken eines Strings im Terminal) zur Verfügung stellt. Außerdem schränkt das Betriebssystem das ausführende User-Programm auf einen festen Bereich des RAMs ein und verhindert so direkten Zugriff auf die Hardware.\r\n\r\nIm Talk möchte ich euch durch alle Schichten dieser Konstruktion führen:\r\nVon den Grundlagen der Factorio-Signalphysik über CPU-Design und Pipeline-Hazards bis zur Toolchain und dem Betriebssystem. Außerdem gebe ich einen Einblick, wie die Limitierungen aber auch die Vorteile von Factorio im Vergleich zu herkömmlichen Logik Simulatoren das Design einer CPU beeinflussen können. Ich runde meinen Talk mit einer Live-Demonstration des Systems ab.\r\n\r\nDie vollständige CPU, inklusive Quellcode des Assemblers, Blueprints und Beispielprogramme, stelle ich öffentlich zur Verfügung. Dadurch kann jede interessierte Person die Architektur in Factorio laden, erweitern und eigene Software dafür entwickeln.\r\n\r\nEs wird im Anschluss eine [Self-organized Session](https://events.ccc.de/congress/2025/hub/en/event/detail/cpu-entwicklung-in-factorio-wie-benutze-ich-phds-f) geben, in der ich eine hands-on Einleitung geben werde, wie man die CPU in Factorio lädt, wie man Programme schreibt, diese assembliert und in Factorio einfügt. Auch kann man dort gerne mit mir über das Projekt quatschen, ich freue mich auf alle Beiträge und Kommentare :)\r\n\r\nLicensed to the public under http://creativecommons.org/licenses/by/4.0", + "original_language": "deu", + "persons": [ + "PhD (Philipp)" + ], + "tags": [ + "1562", + "2025", + "39c3", + "Hardware", + "Ground", + "39c3-deu", + "39c3-eng", + "Day 2" + ], + "view_count": 15682, + "promoted": false, + "date": "2025-12-28T19:15:00.000+01:00", + "release_date": "2025-12-28T00:00:00.000+01:00", + "updated_at": "2026-03-02T17:15:08.794+01:00", + "length": 2312, + "duration": 2312, + "thumb_url": "https://static.media.ccc.de/media/congress/2025/1562-6189eca4-8ac2-5606-af23-628b82eb4a54.jpg", + "poster_url": "https://static.media.ccc.de/media/congress/2025/1562-6189eca4-8ac2-5606-af23-628b82eb4a54_preview.jpg", + "timeline_url": "https://static.media.ccc.de/media/congress/2025/1562-6189eca4-8ac2-5606-af23-628b82eb4a54.timeline.jpg", + "thumbnails_url": "https://static.media.ccc.de/media/congress/2025/1562-6189eca4-8ac2-5606-af23-628b82eb4a54.thumbnails.vtt", + "frontend_link": "https://media.ccc.de/v/39c3-cpu-entwicklung-in-factorio-vom-d-flip-flop-bis-zum-eigenen-betriebssystem", + "url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_title": "39C3: Power Cycles", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3", + "related": [], + "recordings": [ + { + "size": 406, + "length": 2312, + "mime_type": "video/webm;codecs=av01", + "language": "deu-eng", + "filename": "39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_av1-hd.webm", + "state": "new", + "folder": "av1-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-29T02:04:53.430+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/av1-hd/39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_av1-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/94677", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": null, + "length": null, + "mime_type": "text/vtt", + "language": "deu", + "filename": "1562-6189eca4-8ac2-5606-af23-628b82eb4a54-deu.vtt", + "state": "auto", + "folder": "", + "high_quality": true, + "width": null, + "height": null, + "updated_at": "2025-12-30T02:32:23.290+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/1562-6189eca4-8ac2-5606-af23-628b82eb4a54-deu.vtt", + "url": "https://api.media.ccc.de/public/recordings/95345", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 130, + "length": 2312, + "mime_type": "video/webm", + "language": "deu-eng", + "filename": "39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_webm-sd.webm", + "state": "new", + "folder": "webm-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-29T01:35:42.286+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-sd/39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_webm-sd.webm", + "url": "https://api.media.ccc.de/public/recordings/94629", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 442, + "length": 2312, + "mime_type": "video/webm", + "language": "deu-eng", + "filename": "39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_webm-hd.webm", + "state": "new", + "folder": "webm-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-29T01:23:11.102+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/webm-hd/39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_webm-hd.webm", + "url": "https://api.media.ccc.de/public/recordings/94620", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 156, + "length": 2312, + "mime_type": "video/mp4", + "language": "deu-eng", + "filename": "39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_sd.mp4", + "state": "new", + "folder": "h264-sd", + "high_quality": false, + "width": 720, + "height": 576, + "updated_at": "2025-12-29T01:14:26.224+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-sd/39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_sd.mp4", + "url": "https://api.media.ccc.de/public/recordings/94611", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 22, + "length": 2312, + "mime_type": "audio/opus", + "language": "deu", + "filename": "39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_opus.opus", + "state": "new", + "folder": "opus", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-29T14:35:24.108+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus/39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_opus.opus", + "url": "https://api.media.ccc.de/public/recordings/94609", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 25, + "length": 2312, + "mime_type": "audio/opus", + "language": "eng", + "filename": "39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_opus-2.opus", + "state": "new", + "folder": "opus-translation", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-29T00:49:00.369+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/opus-translation/39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_opus-2.opus", + "url": "https://api.media.ccc.de/public/recordings/94595", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 35, + "length": 2312, + "mime_type": "audio/mpeg", + "language": "eng", + "filename": "39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_mp3-2.mp3", + "state": "new", + "folder": "mp3-translated", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-29T00:48:56.445+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3-translated/39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_mp3-2.mp3", + "url": "https://api.media.ccc.de/public/recordings/94594", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 35, + "length": 2312, + "mime_type": "audio/mpeg", + "language": "deu", + "filename": "39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_mp3.mp3", + "state": "new", + "folder": "mp3", + "high_quality": false, + "width": 0, + "height": 0, + "updated_at": "2025-12-29T14:34:58.343+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/mp3/39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_mp3.mp3", + "url": "https://api.media.ccc.de/public/recordings/94593", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 693, + "length": 2312, + "mime_type": "video/mp4", + "language": "deu-eng", + "filename": "39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_hd.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T21:47:42.233+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1562-deu-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem_hd.mp4", + "url": "https://api.media.ccc.de/public/recordings/94477", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 401, + "length": 2312, + "mime_type": "video/mp4", + "language": "eng", + "filename": "39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T21:47:28.255+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1562-eng-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem.mp4", + "url": "https://api.media.ccc.de/public/recordings/94476", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + }, + { + "size": 402, + "length": 2312, + "mime_type": "video/mp4", + "language": "deu", + "filename": "39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem.mp4", + "state": "new", + "folder": "h264-hd", + "high_quality": true, + "width": 1920, + "height": 1080, + "updated_at": "2025-12-28T21:47:18.313+01:00", + "recording_url": "https://cdn.media.ccc.de/congress/2025/h264-hd/39c3-1562-deu-CPU_Entwicklung_in_Factorio_Vom_D-Flip-Flop_bis_zum_eigenen_Betriebssystem.mp4", + "url": "https://api.media.ccc.de/public/recordings/94475", + "event_url": "https://api.media.ccc.de/public/events/6189eca4-8ac2-5606-af23-628b82eb4a54", + "conference_url": "https://api.media.ccc.de/public/conferences/39c3" + } + ] +} \ No newline at end of file diff --git a/HackerTubeTests/URLParserTests.swift b/HackerTubeTests/URLParserTests.swift index 056bed3..973531b 100644 --- a/HackerTubeTests/URLParserTests.swift +++ b/HackerTubeTests/URLParserTests.swift @@ -5,27 +5,35 @@ // Created by Mathijs Bernson on 30/07/2022. // -import XCTest - +import Foundation +import Testing @testable import HackerTube -class URLParserTests: XCTestCase { +@Suite("URLParser") +struct URLParserTests { let parser = URLParser() - func testParseOpenURL() { - let url = URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895")! - XCTAssertEqual(parser.parseURL(url), .openTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895")) - } - - func testParsePlayURL() { - let url = URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895/play")! - XCTAssertEqual(parser.parseURL(url), .playTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895")) + @Test("Parses valid ccctube:// URLs", arguments: zip( + [ + URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895")!, + URL(string: "ccctube://talk/44ab627f-ed5d-522b-b84b-15a3ed761895/play")!, + ], + [ + URLRoute.openTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895"), + URLRoute.playTalk(id: "44ab627f-ed5d-522b-b84b-15a3ed761895"), + ] + )) + func parsesValidURL(_ url: URL, expectedRoute: URLRoute) { + #expect(parser.parseURL(url) == expectedRoute) } - func testInvalidURLs() { - XCTAssertNil(parser.parseURL(URL(string: "ccctube://")!)) - XCTAssertNil(parser.parseURL(URL(string: "https://google.com/")!)) - XCTAssertNil(parser.parseURL(URL(string: "ccctube://foo")!)) - XCTAssertNil(parser.parseURL(URL(string: "mailto:info@example.com")!)) + @Test("Returns nil for invalid URLs", arguments: [ + "ccctube://", + "https://google.com/", + "ccctube://foo", + "mailto:info@example.com", + ]) + func returnsNilForInvalidURL(_ urlString: String) { + #expect(parser.parseURL(URL(string: urlString)!) == nil) } }